nice changes
This commit is contained in:
parent
9b07c526a5
commit
948a219712
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"editor.tabSize": 2
|
||||||
|
}
|
|
@ -80,7 +80,7 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
|
||||||
tokenService := tokenservice.NewTokenService(storage)
|
tokenService := tokenservice.NewTokenService(storage)
|
||||||
|
|
||||||
// Create handlers
|
// Create handlers
|
||||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
|
||||||
shortHandler := shortserver.NewShortHandler(shortService)
|
shortHandler := shortserver.NewShortHandler(shortService)
|
||||||
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
|
||||||
tokenService := tokenservice.NewTokenService(storage)
|
tokenService := tokenservice.NewTokenService(storage)
|
||||||
|
|
||||||
// Create handlers
|
// Create handlers
|
||||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
|
||||||
shortHandler := shortserver.NewShortHandler(shortService)
|
shortHandler := shortserver.NewShortHandler(shortService)
|
||||||
staticHandler := staticssterver.NewStaticHandler(cfg)
|
staticHandler := staticssterver.NewStaticHandler(cfg)
|
||||||
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
||||||
|
|
|
@ -14,7 +14,7 @@ import {
|
||||||
} from "@heroicons/react/24/outline"
|
} from "@heroicons/react/24/outline"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
import { useDoubleclickDelete } from "../hooks/useOnUpdateItem"
|
import { useDoubleclickDelete } from "../hooks/useCRUD"
|
||||||
|
|
||||||
import Button from "./Button"
|
import Button from "./Button"
|
||||||
|
|
||||||
|
@ -22,12 +22,13 @@ const ItemBase: FunctionComponent<
|
||||||
PropsWithChildren<{
|
PropsWithChildren<{
|
||||||
copyString?: string
|
copyString?: string
|
||||||
doDelete?: () => void
|
doDelete?: () => void
|
||||||
detailsPaage?: string
|
detailsPage?: string
|
||||||
|
deleting?: boolean
|
||||||
}>
|
}>
|
||||||
> = ({ children, doDelete, copyString, detailsPaage }) => {
|
> = ({ children, doDelete, copyString, detailsPage, deleting }) => {
|
||||||
const [copied, copy] = useClipboardTimeout(copyString || "")
|
const [copied, copy] = useClipboardTimeout(copyString || "")
|
||||||
|
|
||||||
const [deleting, triggerDelete] = useDoubleclickDelete(
|
const [armed, triggerDelete] = useDoubleclickDelete(
|
||||||
useMemo(() => doDelete ?? (() => {}), [doDelete])
|
useMemo(() => doDelete ?? (() => {}), [doDelete])
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
@ -45,15 +46,16 @@ const ItemBase: FunctionComponent<
|
||||||
<Button
|
<Button
|
||||||
onClick={triggerDelete}
|
onClick={triggerDelete}
|
||||||
className="px-2 py-1 text-sm"
|
className="px-2 py-1 text-sm"
|
||||||
|
disabled={deleting}
|
||||||
color="red">
|
color="red">
|
||||||
<TrashIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
|
<TrashIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
|
||||||
{deleting ? "Are you sure?" : "Delete"}
|
{armed ? "Are you sure?" : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{detailsPaage && (
|
{detailsPage && (
|
||||||
<Link to={`/sht/${name}`}>
|
<Link to={detailsPage}>
|
||||||
<div className="py-1 my-1 text-sm flex flex-row align-middle justify-items-center text-blue-500 hover:text-blue-600 transition-colors duration-200">
|
<div className="py-1 my-1 text-sm flex flex-row align-middle justify-items-center text-blue-500 hover:text-blue-600 transition-colors duration-200">
|
||||||
<span className="my-auto">details</span>
|
<span className="my-auto">details</span>
|
||||||
|
|
||||||
|
|
|
@ -5,23 +5,17 @@ const ItemList = <T extends Record<string, unknown>, K extends keyof T>({
|
||||||
items,
|
items,
|
||||||
Item,
|
Item,
|
||||||
idKey,
|
idKey,
|
||||||
deleteItem,
|
|
||||||
}: {
|
}: {
|
||||||
items: T[]
|
items: T[]
|
||||||
idKey: K
|
idKey: K
|
||||||
Item: FunctionComponent<T & { doDelete: () => void }>
|
Item: FunctionComponent<T>
|
||||||
deleteItem: (key: T[K]) => () => void
|
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
role="list"
|
role="list"
|
||||||
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Item
|
<Item {...item} key={item[idKey] as string} />
|
||||||
{...item}
|
|
||||||
doDelete={deleteItem(item[idKey])}
|
|
||||||
key={item[idKey] as string}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { FunctionComponent } from "react"
|
||||||
|
|
||||||
|
const NoItems: FunctionComponent<{ type: string }> = ({ type }) => {
|
||||||
|
return (
|
||||||
|
<div className="text-center pt-5 text-xl font-light">
|
||||||
|
{`No ${type}s yet`}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoItems
|
|
@ -1,20 +1,30 @@
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useDelete } from "../hooks/useCRUD"
|
||||||
import { Session } from "../types"
|
import { Session } from "../types"
|
||||||
|
|
||||||
const SessionItem: FunctionComponent<Session & { doDelete: () => void }> = ({
|
import ItemBase from "./ItemBase"
|
||||||
doDelete,
|
|
||||||
...session
|
const SessionItem: FunctionComponent<Session> = ({ ...session }) => {
|
||||||
}) => {
|
// Handle deletion
|
||||||
|
const [deleting, deleteOther] = useDelete()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const deleteCurrent = useCallback(() => navigate("/lgo"), [navigate])
|
||||||
|
|
||||||
|
const doDelete = useCallback(() => {
|
||||||
|
if (session.current) {
|
||||||
|
return deleteCurrent()
|
||||||
|
} else {
|
||||||
|
return deleteOther({ id: session.id })
|
||||||
|
}
|
||||||
|
}, [deleteCurrent, deleteOther, session])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
|
<ItemBase doDelete={doDelete} deleting={deleting}>
|
||||||
{JSON.stringify(session)}
|
{JSON.stringify(session)}
|
||||||
<button
|
</ItemBase>
|
||||||
onClick={doDelete}
|
|
||||||
className="text-red-500 group-hover:text-red-600">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,33 +1,40 @@
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
} from "@heroicons/react/24/outline"
|
} from "@heroicons/react/24/outline"
|
||||||
|
|
||||||
|
import { useDelete } from "../hooks/useCRUD"
|
||||||
import { Short } from "../types"
|
import { Short } from "../types"
|
||||||
|
|
||||||
import ItemBase from "./ItemBase"
|
import ItemBase from "./ItemBase"
|
||||||
|
|
||||||
const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
const ShortItem: FunctionComponent<Short> = ({ ...short }) => {
|
||||||
name,
|
|
||||||
url,
|
|
||||||
doDelete,
|
|
||||||
}) => {
|
|
||||||
const origin = location.origin
|
const origin = location.origin
|
||||||
const host = "marona.to"
|
const host = "marona.to"
|
||||||
|
|
||||||
const maxSize = 120
|
const maxSize = 120
|
||||||
const displayURL =
|
const displayURL =
|
||||||
url.length >= maxSize - 3 ? `${url.slice(0, maxSize)}...` : url
|
short.url.length >= maxSize - 3
|
||||||
|
? `${short.url.slice(0, maxSize)}...`
|
||||||
|
: short.url
|
||||||
|
|
||||||
const shortNameURL = `${origin}/${name}`
|
const shortNameURL = `${origin}/${name}`
|
||||||
|
|
||||||
|
// Handle deletion
|
||||||
|
const [deleting, del] = useDelete()
|
||||||
|
const doDelete = useCallback(
|
||||||
|
() => del({ name: short.name }),
|
||||||
|
[del, short.name]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemBase
|
<ItemBase
|
||||||
copyString={shortNameURL}
|
copyString={shortNameURL}
|
||||||
doDelete={doDelete}
|
doDelete={doDelete}
|
||||||
detailsPaage={`/sht/${name}`}>
|
deleting={deleting}
|
||||||
|
detailsPage={`/sht/${short.name}`}>
|
||||||
<div className="grid md:grid-cols-12 md:grid-rows-1 w-full grid-flow-row md:grid-flow-col">
|
<div className="grid md:grid-cols-12 md:grid-rows-1 w-full grid-flow-row md:grid-flow-col">
|
||||||
<div className="col-span-5 md:col-span-3 my-auto flex flex-col order-1">
|
<div className="col-span-5 md:col-span-3 my-auto flex flex-col order-1">
|
||||||
<a
|
<a
|
||||||
|
@ -37,7 +44,7 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
className="flex flex-row font-semibold leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
className="flex flex-row font-semibold leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
||||||
<span className="break-all">
|
<span className="break-all">
|
||||||
<span className="text-sm font-light text-blue-500">{host}/</span>
|
<span className="text-sm font-light text-blue-500">{host}/</span>
|
||||||
<span className="text-base">{name}</span>
|
<span className="text-base">{short.name}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
|
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
|
||||||
|
@ -49,8 +56,8 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-10 md:col-span-6 my-auto order-4 md:order-3">
|
<div className="col-span-10 md:col-span-6 my-auto order-4 md:order-3">
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={short.url}
|
||||||
title={url}
|
title={short.url}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex flex-row font-normal leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
className="flex flex-row font-normal leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
||||||
<span className="break-all">
|
<span className="break-all">
|
||||||
|
|
|
@ -1,12 +1,18 @@
|
||||||
import { FunctionComponent } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
|
import { useDelete } from "../hooks/useCRUD"
|
||||||
import { Token } from "../types"
|
import { Token } from "../types"
|
||||||
|
|
||||||
const TokenItem: FunctionComponent<Token & { doDelete: () => void }> = () => {
|
import ItemBase from "./ItemBase"
|
||||||
|
|
||||||
|
const TokenItem: FunctionComponent<Token> = ({ ...token }) => {
|
||||||
|
const [deleting, del] = useDelete()
|
||||||
|
const doDelete = useCallback(() => del({ id: token.id }), [del, token.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
|
<ItemBase copyString={token.value} doDelete={doDelete} deleting={deleting}>
|
||||||
name
|
{JSON.stringify(token)}
|
||||||
</li>
|
</ItemBase>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import { SetStateAction, useCallback, useEffect, useState } from "react"
|
import { SetStateAction, useCallback, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { useNavigation, useSubmit } from "react-router-dom"
|
||||||
|
import { SubmitTarget } from "react-router-dom/dist/dom"
|
||||||
|
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
export const useOnDelete = <T extends Record<string, unknown>>(
|
export const useOnDelete = <T extends Record<string, unknown>>(
|
||||||
|
@ -20,28 +23,26 @@ export const useOnDelete = <T extends Record<string, unknown>>(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDoubleclickDelete = (
|
export const useDoubleclickDelete = (doDelete: () => void) => {
|
||||||
doDelete: () => void
|
const [armed, setDeleting] = useState(false)
|
||||||
): [boolean, () => void] => {
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deleting) {
|
if (armed) {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
setDeleting(false)
|
setDeleting(false)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}, [deleting])
|
}, [armed])
|
||||||
|
|
||||||
const trigger = useCallback(() => {
|
const trigger = useCallback(() => {
|
||||||
if (deleting) {
|
if (armed) {
|
||||||
doDelete()
|
doDelete()
|
||||||
} else {
|
} else {
|
||||||
setDeleting(true)
|
setDeleting(true)
|
||||||
}
|
}
|
||||||
}, [doDelete, deleting])
|
}, [doDelete, armed])
|
||||||
|
|
||||||
return [deleting, trigger]
|
return [armed, trigger] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useOnCreate = <T extends Record<string, unknown>>(
|
export const useOnCreate = <T extends Record<string, unknown>>(
|
||||||
|
@ -71,3 +72,59 @@ export const useOnCreate = <T extends Record<string, unknown>>(
|
||||||
|
|
||||||
return [creating, create] as const
|
return [creating, create] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useCreate = <T extends SubmitTarget>(
|
||||||
|
onCreate?: (payload?: T) => void
|
||||||
|
) => {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const create = useCallback(
|
||||||
|
(payload?: T) => {
|
||||||
|
setCreating(true)
|
||||||
|
submit(payload ?? null, {
|
||||||
|
method: "POST",
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
if (onCreate) onCreate(payload)
|
||||||
|
},
|
||||||
|
[submit, onCreate]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigation.state === "idle") {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
return [creating, create] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDelete = <T extends SubmitTarget>(
|
||||||
|
onDelete?: (payload: T) => void
|
||||||
|
) => {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const del = useCallback(
|
||||||
|
(payload: T) => {
|
||||||
|
setDeleting(true)
|
||||||
|
submit(payload ?? null, {
|
||||||
|
method: "DELETE",
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
if (onDelete) onDelete(payload)
|
||||||
|
},
|
||||||
|
[submit, onDelete]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (navigation.state === "idle") {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
|
return [deleting, del] as const
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
|
import { useLoaderData } from "react-router-dom"
|
||||||
|
|
||||||
|
type Item = Record<string, unknown>
|
||||||
|
|
||||||
|
export const useLoadedItems = <T extends Item>() => {
|
||||||
|
const sourceDefault = useMemo(() => [], [])
|
||||||
|
const items = (useLoaderData() ?? sourceDefault) as T[]
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSortedItems = <T extends Item>(
|
||||||
|
items: T[],
|
||||||
|
sort: (a: T, b: T) => number
|
||||||
|
) => {
|
||||||
|
const [sortedItems, setSortedItems] = useState([...items].sort(sort))
|
||||||
|
useEffect(() => {
|
||||||
|
setSortedItems([...items].sort(sort))
|
||||||
|
}, [items, sort])
|
||||||
|
|
||||||
|
const pushSortedItem = useCallback(
|
||||||
|
(item: T) => {
|
||||||
|
setSortedItems((items) => [...items, item].sort(sort))
|
||||||
|
},
|
||||||
|
[sort]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeSortedItem = useCallback(
|
||||||
|
(item: T) => {
|
||||||
|
setSortedItems((items) => items.filter((i) => i !== item).sort(sort))
|
||||||
|
},
|
||||||
|
[sort]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [sortedItems, pushSortedItem, removeSortedItem] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSortedLoadedItems = <T extends Item>(
|
||||||
|
sort: (a: T, b: T) => number
|
||||||
|
) => {
|
||||||
|
const items = useLoadedItems<T>()
|
||||||
|
return useSortedItems(items, sort)
|
||||||
|
}
|
|
@ -1,22 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
|
|
||||||
import { useLoaderData } from "react-router-dom"
|
|
||||||
|
|
||||||
const useSortedLoadedItems = <T extends object>(
|
|
||||||
sort: (a: T, b: T) => number
|
|
||||||
) => {
|
|
||||||
const sourceDefault = useMemo(() => [], [])
|
|
||||||
const source = (useLoaderData() ?? sourceDefault) as T[]
|
|
||||||
|
|
||||||
const [data, setData] = useState([...source].sort(sort))
|
|
||||||
|
|
||||||
// Auto update data when source changes
|
|
||||||
useEffect(() => {
|
|
||||||
setData([...source].sort(sort))
|
|
||||||
}, [source, sort])
|
|
||||||
|
|
||||||
// Return the sorted data and a way to update it
|
|
||||||
return [data, setData] as const
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useSortedLoadedItems
|
|
|
@ -1,71 +1,167 @@
|
||||||
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Form,
|
ChangeEvent,
|
||||||
LoaderFunction,
|
FormEvent,
|
||||||
useActionData,
|
FunctionComponent,
|
||||||
useNavigation,
|
useCallback,
|
||||||
} from "react-router-dom"
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
import { useNavigation, useSubmit, useActionData } from "react-router-dom"
|
||||||
|
|
||||||
import Button from "../components/Button"
|
import Button from "../components/Button"
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
import ItemList from "../components/ItemList"
|
import ItemList from "../components/ItemList"
|
||||||
import ShortItem from "../components/ShortItem"
|
import ShortItem from "../components/ShortItem"
|
||||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
|
||||||
import { Short } from "../types"
|
import { Short } from "../types"
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import { crudAction } from "../util/action"
|
||||||
|
import { FetchAPIResult } from "../util/fetchAPI"
|
||||||
|
|
||||||
type ActionResponse =
|
import { action as shortAction } from "./Shorts"
|
||||||
| {
|
|
||||||
short: Short
|
|
||||||
}
|
|
||||||
| { error: string }
|
|
||||||
|
|
||||||
export const Component: FunctionComponent = () => {
|
/**
|
||||||
const form = useRef<HTMLFormElement>(null)
|
* useSubmitActions provides callbacks that are called when a short is created,
|
||||||
|
* deleted, or an error occurs.
|
||||||
|
*/
|
||||||
|
const useSubmitActions = ({
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
onError,
|
||||||
|
onIdle,
|
||||||
|
}: {
|
||||||
|
onCreate?: (short: Short) => void
|
||||||
|
onDelete?: (name: string) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onIdle?: () => void
|
||||||
|
}) => {
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const isShortening = navigation.formData?.get("url") != null
|
const actionData = useActionData() as
|
||||||
|
| FetchAPIResult<Short | string>
|
||||||
|
| undefined
|
||||||
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value.replace(/[^a-z0-9-_]/gi, "")
|
|
||||||
setName(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionData = useActionData() as ActionResponse | undefined
|
|
||||||
|
|
||||||
const [shorts, setShorts] = useState<Short[]>(
|
|
||||||
useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
name: "test",
|
|
||||||
url: "https://example.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (actionData && "short" in actionData) {
|
if (onCreate && actionData && actionData.ok) {
|
||||||
setShorts((shorts) => [actionData.short, ...shorts])
|
const data = actionData.data
|
||||||
// If success, also reset the form and remove focus
|
if (typeof data !== "string") {
|
||||||
form.current?.reset()
|
// Creating
|
||||||
setName("")
|
onCreate(data)
|
||||||
for (const input of form.current?.elements || []) {
|
|
||||||
;(input as HTMLElement).blur()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [actionData])
|
}, [actionData, onCreate])
|
||||||
|
|
||||||
const deleteItem = useOnDelete("/shorts", "name", setShorts)
|
const formData = navigation.formData
|
||||||
|
useEffect(() => {
|
||||||
|
if (onDelete && formData && actionData && actionData.ok) {
|
||||||
|
const data = actionData.data
|
||||||
|
const name = formData.get("name")
|
||||||
|
if (typeof data === "string" && name && typeof name === "string") {
|
||||||
|
// Deleting
|
||||||
|
onDelete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [formData, actionData, onDelete])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onError) {
|
||||||
|
if (onError && actionData && !actionData.ok) {
|
||||||
|
onError(actionData.error)
|
||||||
|
} else {
|
||||||
|
onError("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [actionData, onError])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onIdle && navigation.state === "idle") {
|
||||||
|
onIdle()
|
||||||
|
}
|
||||||
|
}, [navigation.state, onIdle])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*/
|
||||||
|
const useShortForm = () => {
|
||||||
|
const [shortening, setShortening] = useState(false)
|
||||||
|
const [url, setURL] = useState("")
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleURLChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setURL(e.target.value)
|
||||||
|
}, [])
|
||||||
|
const handleNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setName(e.target.value.replace(/[^a-z0-9-_]/gi, ""))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const submit = useSubmit()
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setShortening(true)
|
||||||
|
submit({ url, name }, { method: "POST", replace: true })
|
||||||
|
},
|
||||||
|
[submit, url, name]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset form on create, set error on error
|
||||||
|
useSubmitActions({
|
||||||
|
onCreate: useCallback(() => {
|
||||||
|
setName("")
|
||||||
|
setURL("")
|
||||||
|
}, []),
|
||||||
|
onError: setError,
|
||||||
|
onIdle: useCallback(() => {
|
||||||
|
setShortening(false)
|
||||||
|
}, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
shortening,
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
error,
|
||||||
|
handleURLChange,
|
||||||
|
handleNameChange,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useRecentShorts = () => {
|
||||||
|
const [recentShorts, setRecentShorts] = useState<Short[]>(
|
||||||
|
useMemo(() => [], [])
|
||||||
|
)
|
||||||
|
|
||||||
|
useSubmitActions({
|
||||||
|
onCreate: useCallback((short: Short) => {
|
||||||
|
setRecentShorts((prev) => [short, ...prev])
|
||||||
|
}, []),
|
||||||
|
onDelete: useCallback((name: string) => {
|
||||||
|
setRecentShorts((prev) => prev.filter((short) => short.name !== name))
|
||||||
|
}, []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return recentShorts
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Component: FunctionComponent = () => {
|
||||||
|
const {
|
||||||
|
shortening,
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
error,
|
||||||
|
handleNameChange,
|
||||||
|
handleURLChange,
|
||||||
|
handleSubmit,
|
||||||
|
} = useShortForm()
|
||||||
|
|
||||||
|
const recentShorts = useRecentShorts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="" />
|
<Header title="" />
|
||||||
<Form
|
<form
|
||||||
method="post"
|
onSubmit={handleSubmit}
|
||||||
replace
|
|
||||||
ref={form}
|
|
||||||
className="flex flex-col gap-4 max-w-lg mx-auto w-full h-full text-slate-600">
|
className="flex flex-col gap-4 max-w-lg mx-auto w-full h-full text-slate-600">
|
||||||
<label className="flex flex-col gap-2 xl:gap-4 w-full">
|
<label className="flex flex-col gap-2 xl:gap-4 w-full">
|
||||||
<span className="text-2xl xl:text-4xl font-medium">
|
<span className="text-2xl xl:text-4xl font-medium">
|
||||||
|
@ -80,6 +176,8 @@ export const Component: FunctionComponent = () => {
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
required
|
required
|
||||||
|
value={url}
|
||||||
|
onChange={handleURLChange}
|
||||||
className="p-3 ring-slate-300 text-xl ring-inset ring-1 shadow-sm rounded-md focus:ring-blue-500 outline-0 focus:ring-2 transition-all duration-200"
|
className="p-3 ring-slate-300 text-xl ring-inset ring-1 shadow-sm rounded-md focus:ring-blue-500 outline-0 focus:ring-2 transition-all duration-200"
|
||||||
placeholder="https://example.com/long/url"
|
placeholder="https://example.com/long/url"
|
||||||
/>
|
/>
|
||||||
|
@ -98,67 +196,30 @@ export const Component: FunctionComponent = () => {
|
||||||
name="name"
|
name="name"
|
||||||
minLength={4}
|
minLength={4}
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
value={name}
|
|
||||||
onChange={handleNameChange}
|
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
|
value={name}
|
||||||
|
onChange={handleNameChange}
|
||||||
className="leading-6 py-2 bg-transparent border-0 flex-1 block focus:outline-none placeholder:font-medium placeholder:text-slate-500 font-bold text-blue-600"
|
className="leading-6 py-2 bg-transparent border-0 flex-1 block focus:outline-none placeholder:font-medium placeholder:text-slate-500 font-bold text-blue-600"
|
||||||
placeholder="my-short-link"
|
placeholder="my-short-link"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{actionData && "error" in actionData ? (
|
{error ? (
|
||||||
<p className="text-red-500 text-center font-medium">
|
<p className="text-red-500 text-center font-medium">{error}</p>
|
||||||
{actionData.error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="submit" className="py-3 text-2xl">
|
<Button type="submit" className="py-3 text-2xl">
|
||||||
{isShortening ? "Shortening..." : "Shorten it"}
|
{shortening ? "Shortening..." : "Shorten it"}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</form>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<ItemList
|
<ItemList items={recentShorts} Item={ShortItem} idKey="name" />
|
||||||
items={shorts}
|
|
||||||
Item={ShortItem}
|
|
||||||
idKey="name"
|
|
||||||
deleteItem={deleteItem}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const action: LoaderFunction = async ({
|
export const action = crudAction({
|
||||||
request,
|
POST: shortAction.handlers.POST,
|
||||||
}): Promise<ActionResponse> => {
|
DELETE: shortAction.handlers.DELETE,
|
||||||
if (request.method !== "POST") {
|
})
|
||||||
return { error: "Invalid request method" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData()
|
|
||||||
const url = formData.get("url") as string | null
|
|
||||||
const name = formData.get("name") as string | null
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return { error: "You must provide a URL to shorten" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const body: { url: string; name?: string } = { url }
|
|
||||||
if (name) {
|
|
||||||
body.name = name
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetchAPI<Short>("/shorts", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.data) {
|
|
||||||
return { error: "Something went wrong" }
|
|
||||||
}
|
|
||||||
if (res.ok) {
|
|
||||||
return { short: res.data as Short }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: res.data }
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,60 +1,30 @@
|
||||||
import { FunctionComponent, useCallback, useMemo } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
import { LoaderFunction, redirect, useNavigate } from "react-router-dom"
|
import { LoaderFunction, redirect } from "react-router-dom"
|
||||||
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
import ItemList from "../components/ItemList"
|
||||||
|
import NoItems from "../components/NoItems"
|
||||||
import SessionItem from "../components/SessionItem"
|
import SessionItem from "../components/SessionItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
|
||||||
import { Session } from "../types"
|
import { Session } from "../types"
|
||||||
|
import { crudAction } from "../util/action"
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const [data, setData] = useSortedLoadedItems<Session>(
|
const [items] = useSortedLoadedItems<Session>(
|
||||||
useMemo(() => (a, b) => b.lastActivity.localeCompare(a.lastActivity), [])
|
useCallback((a, b) => b.lastActivity.localeCompare(a.lastActivity), [])
|
||||||
)
|
|
||||||
|
|
||||||
const deleteOther = useOnDelete("/sessions", "id", setData)
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const deleteCurrent = useCallback(() => navigate("/lgo"), [navigate])
|
|
||||||
|
|
||||||
const deleteItem = useCallback(
|
|
||||||
(key: string) => {
|
|
||||||
if (data.find((s) => s.id === key)?.current) {
|
|
||||||
return deleteCurrent
|
|
||||||
} else {
|
|
||||||
return deleteOther(key)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[data, deleteCurrent, deleteOther]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const Sessions: FunctionComponent = () => {
|
const Sessions: FunctionComponent = () => {
|
||||||
return (
|
return <ItemList items={items} Item={SessionItem} idKey="id" />
|
||||||
<ul
|
|
||||||
role="list"
|
|
||||||
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
|
||||||
{data.map((session) => (
|
|
||||||
<SessionItem
|
|
||||||
{...session}
|
|
||||||
doDelete={deleteItem(session.id)}
|
|
||||||
key={session.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const NoSessions = () => {
|
|
||||||
return (
|
|
||||||
<div className="text-center pt-5 text-xl font-light">No sessions yet</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Sessions" />
|
<Header title="Sessions" />
|
||||||
{data.length > 0 ? <Sessions /> : <NoSessions />}
|
{items.length > 0 ? <Sessions /> : <NoItems type="Session" />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -70,4 +40,16 @@ export const loader: LoaderFunction = async (args) => {
|
||||||
return redirect("/lgo")
|
return redirect("/lgo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const action = crudAction({
|
||||||
|
DELETE: async (formData) => {
|
||||||
|
const id = formData.get("id") as string | null
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return { error: "Invalid request", ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAPI<Session>(`/sessions/${id}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Component.displayName = "SessionsPage"
|
Component.displayName = "SessionsPage"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { FunctionComponent, useMemo } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
import { LoaderFunction, redirect } from "react-router-dom"
|
import { LoaderFunction, redirect } from "react-router-dom"
|
||||||
|
|
||||||
|
@ -6,27 +6,18 @@ import Header from "../components/Header"
|
||||||
import ItemList from "../components/ItemList"
|
import ItemList from "../components/ItemList"
|
||||||
import ShortItem from "../components/ShortItem"
|
import ShortItem from "../components/ShortItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
|
||||||
import { Short } from "../types"
|
import { Short } from "../types"
|
||||||
|
import { crudAction } from "../util/action"
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const [shorts, setShorts] = useSortedLoadedItems<Short>(
|
const [items] = useSortedLoadedItems<Short>(
|
||||||
useMemo(() => (a, b) => a.name.localeCompare(b.name), [])
|
useCallback((a, b) => a.name.localeCompare(b.name), [])
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteItem = useOnDelete("/shorts", "name", setShorts)
|
|
||||||
|
|
||||||
const Shorts: FunctionComponent = () => {
|
const Shorts: FunctionComponent = () => {
|
||||||
return (
|
return <ItemList items={items} Item={ShortItem} idKey="name" />
|
||||||
<ItemList
|
|
||||||
items={shorts}
|
|
||||||
Item={ShortItem}
|
|
||||||
idKey="name"
|
|
||||||
deleteItem={deleteItem}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const NoShorts = () => {
|
const NoShorts = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -37,7 +28,7 @@ export function Component() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Shorts" />
|
<Header title="Shorts" />
|
||||||
{shorts.length > 0 ? <Shorts /> : <NoShorts />}
|
{items.length > 0 ? <Shorts /> : <NoShorts />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -53,4 +44,31 @@ export const loader: LoaderFunction = async (args) => {
|
||||||
return redirect("/lgo")
|
return redirect("/lgo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const action = crudAction({
|
||||||
|
POST: async (formData) => {
|
||||||
|
const url = formData.get("url") as string | null
|
||||||
|
const name = formData.get("name") as string | null
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return { error: "Invalid request", ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAPI<Short>("/shorts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ url, name }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
DELETE: async (formData) => {
|
||||||
|
const name = formData.get("name") as string | null
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return { ok: false, error: "Invalid request" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAPI<Short>(`/shorts/${name}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Component.displayName = "ShortsPage"
|
Component.displayName = "ShortsPage"
|
||||||
|
|
|
@ -1,34 +1,30 @@
|
||||||
import { FunctionComponent, useMemo } from "react"
|
import { FunctionComponent, useCallback } from "react"
|
||||||
|
|
||||||
import { PlusIcon } from "@heroicons/react/24/outline"
|
import { PlusIcon } from "@heroicons/react/24/outline"
|
||||||
import { LoaderFunction, redirect } from "react-router-dom"
|
import { LoaderFunction, redirect } from "react-router-dom"
|
||||||
|
|
||||||
import Button from "../components/Button"
|
import Button from "../components/Button"
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
import ItemList from "../components/ItemList"
|
||||||
import TokenItem from "../components/TokenItem"
|
import TokenItem from "../components/TokenItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
import { useOnCreate, useOnDelete } from "../hooks/useOnUpdateItem"
|
import { useCreate } from "../hooks/useCRUD"
|
||||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||||
import { Token } from "../types"
|
import { Token } from "../types"
|
||||||
|
import { crudAction } from "../util/action"
|
||||||
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const [data, setData] = useSortedLoadedItems<Token>(
|
const [items] = useSortedLoadedItems<Token>(
|
||||||
useMemo(() => (a, b) => b.createdAt.localeCompare(a.createdAt), [])
|
useCallback((a, b) => b.createdAt.localeCompare(a.createdAt), [])
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteItem = useOnDelete("/tokens", "id", setData)
|
// Handle creation
|
||||||
const [creating, createItem] = useOnCreate("/tokens", setData)
|
const [creating, create] = useCreate()
|
||||||
|
const onClickCreate = useCallback(() => create(), [create])
|
||||||
|
|
||||||
const Tokens: FunctionComponent = () => {
|
const Tokens: FunctionComponent = () => {
|
||||||
return (
|
return <ItemList items={items} Item={TokenItem} idKey="id" />
|
||||||
<ul
|
|
||||||
role="list"
|
|
||||||
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
|
||||||
{data.map((item) => (
|
|
||||||
<TokenItem {...item} doDelete={deleteItem(item.id)} key={item.id} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const NoTokens = () => {
|
const NoTokens = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -41,12 +37,12 @@ export function Component() {
|
||||||
<Header title="Tokens" />
|
<Header title="Tokens" />
|
||||||
<Button
|
<Button
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
onClick={() => createItem()}
|
onClick={onClickCreate}
|
||||||
className="absolute self-end mt-14 px-4 py-3 text-lg">
|
className="absolute self-end mt-14 px-4 py-3 text-lg">
|
||||||
<PlusIcon className="inline-block w-5 h-5 mb-0.5 mr-1 leading-1" />
|
<PlusIcon className="inline-block w-5 h-5 mb-0.5 mr-1 leading-1" />
|
||||||
<span>New token</span>
|
<span>New token</span>
|
||||||
</Button>
|
</Button>
|
||||||
{data.length > 0 ? <Tokens /> : <NoTokens />}
|
{items.length > 0 ? <Tokens /> : <NoTokens />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -55,12 +51,39 @@ export const loader: LoaderFunction = async (args) => {
|
||||||
const resp = await protectedLoader(args)
|
const resp = await protectedLoader(args)
|
||||||
if (resp) return resp
|
if (resp) return resp
|
||||||
|
|
||||||
// const data = await fetchAPI<Token[]>("/tokens")
|
const data = await fetchAPI<Token[]>("/tokens")
|
||||||
const data = { ok: true, data: [] }
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
return redirect("/lgo")
|
return redirect("/lgo")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const action = crudAction({
|
||||||
|
POST: () => fetchAPI<Token>("/tokens", { method: "POST" }),
|
||||||
|
PATCH: async (formData) => {
|
||||||
|
const id = formData.get("id") as string | null
|
||||||
|
const name = formData.get("name") as string | null
|
||||||
|
|
||||||
|
if (!id || !name) {
|
||||||
|
return { error: "Invalid request", ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAPI<Token>(`/tokens/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
DELETE: async (formData) => {
|
||||||
|
const id = formData.get("id") as string | null
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return { error: "Invalid request", ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchAPI<Token>(`/tokens/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Component.displayName = "TokensPage"
|
Component.displayName = "TokensPage"
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
export type GenericItem = Record<string, unknown>
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
@ -9,7 +11,6 @@ export type Short = {
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
|
||||||
ip: string
|
ip: string
|
||||||
userAgent: string
|
userAgent: string
|
||||||
lastActivity: string
|
lastActivity: string
|
||||||
|
@ -19,6 +20,7 @@ export type Session = {
|
||||||
|
|
||||||
export type Token = {
|
export type Token = {
|
||||||
id: string
|
id: string
|
||||||
|
name: string
|
||||||
value: string
|
value: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { FormMethod, LoaderFunction } from "react-router-dom"
|
||||||
|
|
||||||
|
import { GenericItem } from "../types"
|
||||||
|
|
||||||
|
import { FetchAPIResult } from "./fetchAPI"
|
||||||
|
|
||||||
|
export type ActionHandler<T> = (
|
||||||
|
formData: FormData
|
||||||
|
) => Promise<FetchAPIResult<T>>
|
||||||
|
export type ActionHandlers<T> = {
|
||||||
|
[method in Uppercase<FormMethod>]?: ActionHandler<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
type CRUDActionFunc<T extends GenericItem> = LoaderFunction & {
|
||||||
|
handlers: ActionHandlers<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function crudAction<T extends GenericItem>(handlers: ActionHandlers<T>) {
|
||||||
|
const func: CRUDActionFunc<T> = async ({ request }) => {
|
||||||
|
// Get the method from the request
|
||||||
|
const method = request.method.toUpperCase() as Uppercase<FormMethod>
|
||||||
|
|
||||||
|
// Get handler from handlers
|
||||||
|
const handler = method in handlers ? handlers[method] : undefined
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
return { error: "Method not allowed", ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the form data from the request
|
||||||
|
const formData = await request.formData()
|
||||||
|
|
||||||
|
// Call the handler
|
||||||
|
const res = await handler(formData)
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the handlers to the function
|
||||||
|
func.handlers = handlers
|
||||||
|
|
||||||
|
return func
|
||||||
|
}
|
|
@ -3,13 +3,13 @@ type ErrorResponse = {
|
||||||
error: string
|
error: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result<T> =
|
export type FetchAPIResult<T> =
|
||||||
| {
|
| {
|
||||||
data: T | string
|
data: T
|
||||||
ok: true
|
ok: true
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: string
|
error: string
|
||||||
ok: false
|
ok: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ type Result<T> =
|
||||||
export default async function <T>(
|
export default async function <T>(
|
||||||
path: string,
|
path: string,
|
||||||
args: Parameters<typeof fetch>[1] = {}
|
args: Parameters<typeof fetch>[1] = {}
|
||||||
): Promise<Result<T>> {
|
): Promise<FetchAPIResult<T>> {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
args.credentials = "include"
|
args.credentials = "include"
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ export default async function <T>(
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: (e as Error).message,
|
error: (e as Error).message,
|
||||||
ok: false,
|
ok: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ export default async function <T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data:
|
error:
|
||||||
(dataOrString as ErrorResponse)?.error ||
|
(dataOrString as ErrorResponse)?.error ||
|
||||||
(dataOrString as ErrorResponse)?.status ||
|
(dataOrString as ErrorResponse)?.status ||
|
||||||
response.statusText,
|
response.statusText,
|
||||||
|
|
|
@ -34,6 +34,12 @@ var (
|
||||||
ErrTokenExists = errors.New("token already exists")
|
ErrTokenExists = errors.New("token already exists")
|
||||||
// ErrTokenMissing
|
// ErrTokenMissing
|
||||||
ErrTokenMissing = errors.New("token missing")
|
ErrTokenMissing = errors.New("token missing")
|
||||||
|
// ErrInvalidToken
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
// ErrInvalidTokenID
|
||||||
|
ErrInvalidTokenID = errors.New("invalid token ID")
|
||||||
|
// ErrInvalidTokenName
|
||||||
|
ErrInvalidTokenName = errors.New("invalid token name")
|
||||||
)
|
)
|
||||||
|
|
||||||
func Error(err error, msg string) error {
|
func Error(err error, msg string) error {
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"git.maronato.dev/maronato/goshort/internal/server"
|
"git.maronato.dev/maronato/goshort/internal/server"
|
||||||
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
|
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
|
||||||
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
|
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
|
||||||
|
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
|
||||||
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
||||||
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
@ -18,13 +19,15 @@ import (
|
||||||
type APIHandler struct {
|
type APIHandler struct {
|
||||||
shorts *shortservice.ShortService
|
shorts *shortservice.ShortService
|
||||||
users *userservice.UserService
|
users *userservice.UserService
|
||||||
|
tokens *tokenservice.TokenService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIHandler(shorts *shortservice.ShortService, users *userservice.UserService) *APIHandler {
|
func NewAPIHandler(shorts *shortservice.ShortService, users *userservice.UserService, tokens *tokenservice.TokenService) *APIHandler {
|
||||||
|
|
||||||
return &APIHandler{
|
return &APIHandler{
|
||||||
shorts: shorts,
|
shorts: shorts,
|
||||||
users: users,
|
users: users,
|
||||||
|
tokens: tokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -49,8 +52,14 @@ func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete all user's sessions
|
||||||
|
err := servermiddleware.DeleteAllUserSessions(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the user
|
// Delete the user
|
||||||
err := h.users.DeleteUser(ctx, user)
|
err = h.users.DeleteUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
server.RenderServerError(w, r, err)
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
|
@ -70,20 +79,20 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var login *loginForm
|
var form *loginForm
|
||||||
|
|
||||||
if err := render.DecodeJSON(r.Body, &login); err != nil {
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
||||||
server.RenderBadRequest(w, r, err)
|
server.RenderBadRequest(w, r, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from storage
|
// Get user from storage
|
||||||
user, err := h.users.AuthenticateUser(ctx, login.Username, login.Password)
|
user, err := h.users.AuthenticateUser(ctx, form.Username, form.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
||||||
// If the username or password are wrong, return invalid username/password
|
// If the username or password are wrong, return invalid username/password
|
||||||
server.RenderUnauthorized(w, r, errs.ErrInvalidUsernameOrPassword)
|
server.RenderUnauthorized(w, r)
|
||||||
} else if errors.Is(err, errs.ErrInvalidUser) {
|
} else if errors.Is(err, errs.ErrInvalidUser) {
|
||||||
// If the request was invalid, return bad request
|
// If the request was invalid, return bad request
|
||||||
server.RenderBadRequest(w, r, err)
|
server.RenderBadRequest(w, r, err)
|
||||||
|
@ -147,12 +156,12 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
err := h.users.CreateUser(ctx, user)
|
newUser, err := h.users.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
|
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
|
||||||
server.RenderBadRequest(w, r, err)
|
server.RenderBadRequest(w, r, err)
|
||||||
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
|
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
|
||||||
server.RenderForbidden(w, r, err)
|
server.RenderForbidden(w, r)
|
||||||
} else {
|
} else {
|
||||||
server.RenderServerError(w, r, err)
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
@ -162,7 +171,7 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Render the response
|
// Render the response
|
||||||
render.Status(r, http.StatusCreated)
|
render.Status(r, http.StatusCreated)
|
||||||
render.JSON(w, r, user)
|
render.JSON(w, r, newUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -187,7 +196,7 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
||||||
short.User = user
|
short.User = user
|
||||||
|
|
||||||
// Shorten URL
|
// Shorten URL
|
||||||
short, err := h.shorts.Shorten(ctx, short)
|
newShort, err := h.shorts.Shorten(ctx, short)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
||||||
server.RenderBadRequest(w, r, err)
|
server.RenderBadRequest(w, r, err)
|
||||||
|
@ -200,7 +209,7 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Render the response
|
// Render the response
|
||||||
render.Status(r, http.StatusCreated)
|
render.Status(r, http.StatusCreated)
|
||||||
render.JSON(w, r, short)
|
render.JSON(w, r, newShort)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
|
func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -293,7 +302,7 @@ func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||||
err := servermiddleware.DeleteUserSession(ctx, user, sessionToken)
|
err := servermiddleware.DeleteUserSession(ctx, user, sessionToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrSessionDoesNotExist) {
|
if errors.Is(err, errs.ErrSessionDoesNotExist) {
|
||||||
server.RenderNotFound(w, r, err)
|
server.RenderNotFound(w, r)
|
||||||
} else {
|
} else {
|
||||||
server.RenderServerError(w, r, err)
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
@ -302,7 +311,106 @@ func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the response
|
// Render the response
|
||||||
render.Status(r, http.StatusNoContent)
|
render.NoContent(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens lists all tokens belonging to the user.
|
||||||
|
func (h *APIHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Get user from context
|
||||||
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tokens
|
||||||
|
tokens, err := h.tokens.ListTokens(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the response
|
||||||
|
render.Status(r, http.StatusOK)
|
||||||
|
render.JSON(w, r, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken creates a new token for the user.
|
||||||
|
func (h *APIHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Get user from context
|
||||||
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.tokens.CreateToken(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the response
|
||||||
|
render.Status(r, http.StatusCreated)
|
||||||
|
render.JSON(w, r, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeTokenName changes a token's name belonging to the user.
|
||||||
|
func (h *APIHandler) ChangeTokenName(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
type tokenNameForm struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var form *tokenNameForm
|
||||||
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
||||||
|
server.RenderBadRequest(w, r, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find token or respond
|
||||||
|
token, ok := h.findTokenOrRespond(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename token
|
||||||
|
newToken, err := h.tokens.ChangeTokenName(ctx, token, form.Name)
|
||||||
|
if err != nil {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed. Return the token
|
||||||
|
render.Status(r, http.StatusOK)
|
||||||
|
render.JSON(w, r, newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteToken deletes a token belonging to the user.
|
||||||
|
func (h *APIHandler) DeleteToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Find token or respond
|
||||||
|
token, ok := h.findTokenOrRespond(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete token
|
||||||
|
err := h.tokens.DeleteToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted, return no content
|
||||||
|
render.NoContent(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUserOrRespond is a helper function that finds a user in the session,
|
// findUserOrRespond is a helper function that finds a user in the session,
|
||||||
|
@ -321,7 +429,7 @@ func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (
|
||||||
return user, true
|
return user, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// findShortWithAuth is a helper function that finds a short specified in the request params,
|
// findShortOrRespond is a helper function that finds a short specified in the request params,
|
||||||
// and checks if the user in the session is the same as the short's user. If it is, it returns
|
// and checks if the user in the session is the same as the short's user. If it is, it returns
|
||||||
// the short and true. If it isn't, it returns nil and false.
|
// the short and true. If it isn't, it returns nil and false.
|
||||||
func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request) (short *models.Short, ok bool) {
|
func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request) (short *models.Short, ok bool) {
|
||||||
|
@ -335,7 +443,7 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the short doesn't exist or is invalid, return not found
|
// If the short doesn't exist or is invalid, return not found
|
||||||
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
||||||
server.RenderNotFound(w, r, err)
|
server.RenderNotFound(w, r)
|
||||||
} else {
|
} else {
|
||||||
server.RenderServerError(w, r, err)
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
@ -352,10 +460,49 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
|
||||||
// If the session user does not match the short's user,
|
// If the session user does not match the short's user,
|
||||||
// return forbidden.
|
// return forbidden.
|
||||||
if user.Username != short.User.Username {
|
if user.Username != short.User.Username {
|
||||||
server.RenderForbidden(w, r, fmt.Errorf("this short is not yours"))
|
server.RenderForbidden(w, r)
|
||||||
|
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return short, true
|
return short, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findTokenOrRespond is a helper function that finds a token specified in the request params,
|
||||||
|
// and checks if the user in the session is the same as the tokens's user. If it is, it returns
|
||||||
|
// the token and true. If it isn't, it returns nil and false.
|
||||||
|
func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request) (token *models.Token, ok bool) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Get token ID from request
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
// Find token in storage
|
||||||
|
token, err := h.tokens.FindTokenByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
// If the token doesn't exist or is invalid, return not found
|
||||||
|
if errors.Is(err, errs.ErrTokenDoesNotExist) || errors.Is(err, errs.ErrInvalidToken) {
|
||||||
|
server.RenderNotFound(w, r)
|
||||||
|
} else {
|
||||||
|
server.RenderServerError(w, r, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from context
|
||||||
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the session user does not match the token's user,
|
||||||
|
// return NotFound.
|
||||||
|
if user.Username != token.User.Username {
|
||||||
|
server.RenderNotFound(w, r)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, true
|
||||||
|
}
|
||||||
|
|
|
@ -32,6 +32,12 @@ func NewAPIRouter(h *APIHandler) http.Handler {
|
||||||
// Sessions routes
|
// Sessions routes
|
||||||
r.Get("/sessions", h.ListSessions)
|
r.Get("/sessions", h.ListSessions)
|
||||||
r.Delete("/sessions/{id}", h.DeleteSession)
|
r.Delete("/sessions/{id}", h.DeleteSession)
|
||||||
|
|
||||||
|
// Tokens routes
|
||||||
|
r.Get("/tokens", h.ListTokens)
|
||||||
|
r.Post("/tokens", h.CreateToken)
|
||||||
|
r.Patch("/tokens/{id}", h.ChangeTokenName)
|
||||||
|
r.Delete("/tokens/{id}", h.DeleteToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
|
|
|
@ -25,15 +25,20 @@ func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrGeneric(err error, status int) render.Renderer {
|
func ErrBasic(status int) *ErrResponse {
|
||||||
return &ErrResponse{
|
return &ErrResponse{
|
||||||
Err: err,
|
|
||||||
HTTPStatusCode: status,
|
HTTPStatusCode: status,
|
||||||
StatusText: http.StatusText(status),
|
StatusText: http.StatusText(status),
|
||||||
ErrorText: err.Error(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ErrGeneric(err error, status int) render.Renderer {
|
||||||
|
resp := ErrBasic(status)
|
||||||
|
resp.Err = err
|
||||||
|
resp.ErrorText = err.Error()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
func ErrBadRequest(err error) render.Renderer {
|
func ErrBadRequest(err error) render.Renderer {
|
||||||
return ErrGeneric(err, http.StatusBadRequest)
|
return ErrGeneric(err, http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
@ -42,16 +47,16 @@ func ErrServerError(err error) render.Renderer {
|
||||||
return ErrGeneric(err, http.StatusInternalServerError)
|
return ErrGeneric(err, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrNotFound(err error) render.Renderer {
|
func ErrNotFound() render.Renderer {
|
||||||
return ErrGeneric(err, http.StatusNotFound)
|
return ErrBasic(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrUnauthorized(err error) render.Renderer {
|
func ErrUnauthorized() render.Renderer {
|
||||||
return ErrGeneric(err, http.StatusUnauthorized)
|
return ErrBasic(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrForbidden(err error) render.Renderer {
|
func ErrForbidden() render.Renderer {
|
||||||
return ErrGeneric(err, http.StatusForbidden)
|
return ErrBasic(http.StatusForbidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrRendering(err error) render.Renderer {
|
func ErrRendering(err error) render.Renderer {
|
||||||
|
@ -82,16 +87,16 @@ func RenderServerError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
RenderRender(w, r, ErrServerError(err))
|
RenderRender(w, r, ErrServerError(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderNotFound(w http.ResponseWriter, r *http.Request, err error) {
|
func RenderNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
RenderRender(w, r, ErrNotFound(err))
|
RenderRender(w, r, ErrNotFound())
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderUnauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
func RenderUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||||
RenderRender(w, r, ErrUnauthorized(err))
|
RenderRender(w, r, ErrUnauthorized())
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderForbidden(w http.ResponseWriter, r *http.Request, err error) {
|
func RenderForbidden(w http.ResponseWriter, r *http.Request) {
|
||||||
RenderRender(w, r, ErrForbidden(err))
|
RenderRender(w, r, ErrForbidden())
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderRendering(w http.ResponseWriter, r *http.Request, err error) {
|
func RenderRendering(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
|
|
@ -244,6 +244,23 @@ func DeleteUserSession(ctx context.Context, user *models.User, sessionToken stri
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteAllUserSessions(ctx context.Context, user *models.User) error {
|
||||||
|
err := iterateUserSessions(ctx, user, func(sessionCtx context.Context) error {
|
||||||
|
// Destroy session
|
||||||
|
err := SessionManagerFromCtx(ctx).Destroy(sessionCtx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error destroying session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error iterating through user sessions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func authenticateUserViaSession(r *http.Request, userService *userservice.UserService) (user *models.User, err error) {
|
func authenticateUserViaSession(r *http.Request, userService *userservice.UserService) (user *models.User, err error) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
|
|
|
@ -82,12 +82,12 @@ func (s *ShortService) Shorten(ctx context.Context, short *models.Short) (*model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the short in storage
|
// Save the short in storage
|
||||||
err = s.db.CreateShort(ctx, short)
|
newShort, err := s.db.CreateShort(ctx, short)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.Short{}, fmt.Errorf("could not save short in storage: %w", err)
|
return &models.Short{}, fmt.Errorf("could not save short in storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return short, nil
|
return newShort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ShortService) ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error) {
|
func (s *ShortService) ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error) {
|
||||||
|
|
|
@ -5,9 +5,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||||
"git.maronato.dev/maronato/goshort/internal/storage"
|
"git.maronato.dev/maronato/goshort/internal/storage"
|
||||||
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
||||||
shortutil "git.maronato.dev/maronato/goshort/internal/util/short"
|
shortutil "git.maronato.dev/maronato/goshort/internal/util/short"
|
||||||
|
tokenutil "git.maronato.dev/maronato/goshort/internal/util/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -40,10 +42,10 @@ func NewTokenService(db storage.Storage) *TokenService {
|
||||||
func (s *TokenService) FindToken(ctx context.Context, value string) (*models.Token, error) {
|
func (s *TokenService) FindToken(ctx context.Context, value string) (*models.Token, error) {
|
||||||
// Check if the token has the prefix
|
// Check if the token has the prefix
|
||||||
if !strings.HasPrefix(value, TokenPrefix) {
|
if !strings.HasPrefix(value, TokenPrefix) {
|
||||||
return &models.Token{}, fmt.Errorf("invalid token")
|
return &models.Token{}, errs.ErrInvalidToken
|
||||||
}
|
}
|
||||||
if len(value) != TokenLength+len(TokenPrefix) {
|
if len(value) != TokenLength+len(TokenPrefix) {
|
||||||
return &models.Token{}, fmt.Errorf("invalid token")
|
return &models.Token{}, errs.ErrInvalidToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the token from storage
|
// Get the token from storage
|
||||||
|
@ -59,7 +61,7 @@ func (s *TokenService) FindToken(ctx context.Context, value string) (*models.Tok
|
||||||
func (s *TokenService) FindTokenByID(ctx context.Context, id string) (*models.Token, error) {
|
func (s *TokenService) FindTokenByID(ctx context.Context, id string) (*models.Token, error) {
|
||||||
// Check if the ID is valid
|
// Check if the ID is valid
|
||||||
if len(id) != TokenIDLength {
|
if len(id) != TokenIDLength {
|
||||||
return &models.Token{}, fmt.Errorf("invalid ID")
|
return &models.Token{}, errs.ErrInvalidTokenID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the token from storage
|
// Get the token from storage
|
||||||
|
@ -71,23 +73,34 @@ func (s *TokenService) FindTokenByID(ctx context.Context, id string) (*models.To
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TokenService) ListTokens(ctx context.Context, user *models.User) ([]*models.Token, error) {
|
||||||
|
// Get the tokens from storage
|
||||||
|
tokens, err := s.db.ListTokens(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return tokens, fmt.Errorf("could not get tokens from storage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateToken creates a new token for a user.
|
// CreateToken creates a new token for a user.
|
||||||
func (s *TokenService) CreateToken(ctx context.Context, user *models.User) (*models.Token, error) {
|
func (s *TokenService) CreateToken(ctx context.Context, user *models.User) (*models.Token, error) {
|
||||||
// Generate a new token
|
// Generate a new token
|
||||||
|
id := shortutil.GenerateRandomShort(TokenIDLength)
|
||||||
token := &models.Token{
|
token := &models.Token{
|
||||||
ID: shortutil.GenerateRandomShort(TokenIDLength),
|
ID: id,
|
||||||
Name: user.Username + "'s token",
|
Name: fmt.Sprintf("%s's token #%s", user.Username, id[:5]),
|
||||||
Value: TokenPrefix + shortutil.GenerateRandomShort(TokenLength),
|
Value: TokenPrefix + tokenutil.GenerateSecureToken(TokenLength),
|
||||||
User: user,
|
User: user,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the token in storage
|
// Create the token in storage
|
||||||
err := s.db.CreateToken(ctx, token)
|
newToken, err := s.db.CreateToken(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &models.Token{}, fmt.Errorf("could not create token in storage: %w", err)
|
return &models.Token{}, fmt.Errorf("could not create token in storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return token, nil
|
return newToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteToken deletes a token from the storage.
|
// DeleteToken deletes a token from the storage.
|
||||||
|
@ -100,3 +113,18 @@ func (s *TokenService) DeleteToken(ctx context.Context, token *models.Token) err
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TokenService) ChangeTokenName(ctx context.Context, token *models.Token, name string) (*models.Token, error) {
|
||||||
|
// Make sure the name is valid
|
||||||
|
if len(name) < MinTokenNameLength || len(name) > MaxTokenNameLength {
|
||||||
|
return &models.Token{}, errs.ErrInvalidTokenName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update it
|
||||||
|
newToken, err := s.db.ChangeTokenName(ctx, token, name)
|
||||||
|
if err != nil {
|
||||||
|
return &models.Token{}, fmt.Errorf("could not rename token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newToken, nil
|
||||||
|
}
|
||||||
|
|
|
@ -55,24 +55,24 @@ func (s *UserService) FindUser(ctx context.Context, username string) (*models.Us
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) CreateUser(ctx context.Context, user *models.User) error {
|
func (s *UserService) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
|
||||||
// Check for disabled registration
|
// Check for disabled registration
|
||||||
if s.disableRegistration {
|
if s.disableRegistration {
|
||||||
return errs.ErrRegistrationDisabled
|
return &models.User{}, errs.ErrRegistrationDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is valid
|
// Check if the user is valid
|
||||||
err := UserIsValid(user)
|
err := UserIsValid(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not validate user: %w", err)
|
return &models.User{}, fmt.Errorf("could not validate user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.db.CreateUser(ctx, user)
|
newUser, err := s.db.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not create user in storage: %w", err)
|
return &models.User{}, fmt.Errorf("could not create user in storage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return newUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserService) DeleteUser(ctx context.Context, user *models.User) error {
|
func (s *UserService) DeleteUser(ctx context.Context, user *models.User) error {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.maronato.dev/maronato/goshort/internal/errs"
|
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||||
"git.maronato.dev/maronato/goshort/internal/storage"
|
"git.maronato.dev/maronato/goshort/internal/storage"
|
||||||
|
@ -45,18 +46,18 @@ func (s *MemoryStorage) FindShort(ctx context.Context, name string) (*models.Sho
|
||||||
return short, nil
|
return short, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) CreateShort(ctx context.Context, short *models.Short) error {
|
func (s *MemoryStorage) CreateShort(ctx context.Context, short *models.Short) (*models.Short, error) {
|
||||||
s.shortMu.Lock()
|
s.shortMu.Lock()
|
||||||
defer s.shortMu.Unlock()
|
defer s.shortMu.Unlock()
|
||||||
|
|
||||||
_, ok := s.shortMap[short.Name]
|
_, ok := s.shortMap[short.Name]
|
||||||
if ok {
|
if ok {
|
||||||
return errs.ErrShortExists
|
return &models.Short{}, errs.ErrShortExists
|
||||||
}
|
}
|
||||||
|
|
||||||
s.shortMap[short.Name] = short
|
s.shortMap[short.Name] = short
|
||||||
|
|
||||||
return nil
|
return short, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) DeleteShort(ctx context.Context, short *models.Short) error {
|
func (s *MemoryStorage) DeleteShort(ctx context.Context, short *models.Short) error {
|
||||||
|
@ -100,18 +101,18 @@ func (s *MemoryStorage) FindUser(ctx context.Context, username string) (*models.
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) CreateUser(ctx context.Context, user *models.User) error {
|
func (s *MemoryStorage) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
|
||||||
s.userMu.Lock()
|
s.userMu.Lock()
|
||||||
defer s.userMu.Unlock()
|
defer s.userMu.Unlock()
|
||||||
|
|
||||||
_, ok := s.userMap[user.Username]
|
_, ok := s.userMap[user.Username]
|
||||||
if ok {
|
if ok {
|
||||||
return errs.ErrUserExists
|
return &models.User{}, errs.ErrUserExists
|
||||||
}
|
}
|
||||||
|
|
||||||
s.userMap[user.Username] = user
|
s.userMap[user.Username] = user
|
||||||
|
|
||||||
return nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error {
|
func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error {
|
||||||
|
@ -137,6 +138,20 @@ func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find all user tokens
|
||||||
|
tokens, err := s.ListTokens(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not list user tokens: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all user tokens
|
||||||
|
for _, token := range tokens {
|
||||||
|
err = s.DeleteToken(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not delete user token: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
delete(s.userMap, user.Username)
|
delete(s.userMap, user.Username)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -181,23 +196,25 @@ func (s *MemoryStorage) ListTokens(ctx context.Context, user *models.User) ([]*m
|
||||||
return tokens, nil
|
return tokens, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) CreateToken(ctx context.Context, token *models.Token) error {
|
func (s *MemoryStorage) CreateToken(ctx context.Context, token *models.Token) (*models.Token, error) {
|
||||||
s.tokenMu.Lock()
|
s.tokenMu.Lock()
|
||||||
defer s.tokenMu.Unlock()
|
defer s.tokenMu.Unlock()
|
||||||
|
|
||||||
_, ok := s.tokenMap[token.Value]
|
_, ok := s.tokenMap[token.Value]
|
||||||
if ok {
|
if ok {
|
||||||
return errs.ErrTokenExists
|
return &models.Token{}, errs.ErrTokenExists
|
||||||
}
|
}
|
||||||
_, ok = s.tokenIDMap[token.ID]
|
_, ok = s.tokenIDMap[token.ID]
|
||||||
if ok {
|
if ok {
|
||||||
return errs.ErrTokenExists
|
return &models.Token{}, errs.ErrTokenExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token.CreatedAt = time.Now()
|
||||||
|
|
||||||
s.tokenMap[token.Value] = token
|
s.tokenMap[token.Value] = token
|
||||||
s.tokenIDMap[token.ID] = token
|
s.tokenIDMap[token.ID] = token
|
||||||
|
|
||||||
return nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MemoryStorage) DeleteToken(ctx context.Context, token *models.Token) error {
|
func (s *MemoryStorage) DeleteToken(ctx context.Context, token *models.Token) error {
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type Token struct {
|
type Token struct {
|
||||||
// ID is the unique identifier of the token.
|
// ID is the unique identifier of the token
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
// Name is the user-friendly name of the token.
|
// Name is the user-friendly name of the token.
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Value is the actual token.
|
// Value is the actual token.
|
||||||
Value string `json:"token"`
|
Value string `json:"value"`
|
||||||
|
// CreatedAt is when the token was created (initialized by the storage)
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
|
||||||
// User is the user that created the token.
|
// User is the user that created the token.
|
||||||
User *User `json:"-"`
|
User *User `json:"-"`
|
||||||
|
|
|
@ -14,7 +14,7 @@ type Storage interface {
|
||||||
// FindShorts finds all shorts in the storage that belong to a user.
|
// FindShorts finds all shorts in the storage that belong to a user.
|
||||||
ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error)
|
ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error)
|
||||||
// CreateShort creates a short in the storage.
|
// CreateShort creates a short in the storage.
|
||||||
CreateShort(ctx context.Context, short *models.Short) error
|
CreateShort(ctx context.Context, short *models.Short) (*models.Short, error)
|
||||||
// DeleteShort deletes a short from the storage.
|
// DeleteShort deletes a short from the storage.
|
||||||
DeleteShort(ctx context.Context, short *models.Short) error
|
DeleteShort(ctx context.Context, short *models.Short) error
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ type Storage interface {
|
||||||
// FindUser finds a user in the storage using its username.
|
// FindUser finds a user in the storage using its username.
|
||||||
FindUser(ctx context.Context, username string) (*models.User, error)
|
FindUser(ctx context.Context, username string) (*models.User, error)
|
||||||
// CreateUser creates a user in the storage.
|
// CreateUser creates a user in the storage.
|
||||||
CreateUser(ctx context.Context, user *models.User) error
|
CreateUser(ctx context.Context, user *models.User) (*models.User, error)
|
||||||
// DeleteUser deletes a user and all their shorts from the storage.
|
// DeleteUser deletes a user and all their shorts from the storage.
|
||||||
DeleteUser(ctx context.Context, user *models.User) error
|
DeleteUser(ctx context.Context, user *models.User) error
|
||||||
|
|
||||||
|
@ -36,7 +36,9 @@ type Storage interface {
|
||||||
// ListTokens finds all tokens in the storage that belong to a user.
|
// ListTokens finds all tokens in the storage that belong to a user.
|
||||||
ListTokens(ctx context.Context, user *models.User) ([]*models.Token, error)
|
ListTokens(ctx context.Context, user *models.User) ([]*models.Token, error)
|
||||||
// CreateToken creates a token in the storage.
|
// CreateToken creates a token in the storage.
|
||||||
CreateToken(ctx context.Context, token *models.Token) error
|
CreateToken(ctx context.Context, token *models.Token) (*models.Token, error)
|
||||||
|
// ChangeTokenName changes the name of a token
|
||||||
|
ChangeTokenName(ctx context.Context, token *models.Token, name string) (*models.Token, error)
|
||||||
// DeleteToken deletes a token from the storage.
|
// DeleteToken deletes a token from the storage.
|
||||||
DeleteToken(ctx context.Context, token *models.Token) error
|
DeleteToken(ctx context.Context, token *models.Token) error
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
package tokenutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSecureToken(length int) string {
|
||||||
|
b := make([]byte, length/2)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user