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)
|
||||
|
||||
// Create handlers
|
||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
||||
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
|
||||
shortHandler := shortserver.NewShortHandler(shortService)
|
||||
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
|
|||
tokenService := tokenservice.NewTokenService(storage)
|
||||
|
||||
// Create handlers
|
||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
||||
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
|
||||
shortHandler := shortserver.NewShortHandler(shortService)
|
||||
staticHandler := staticssterver.NewStaticHandler(cfg)
|
||||
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "@heroicons/react/24/outline"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { useDoubleclickDelete } from "../hooks/useOnUpdateItem"
|
||||
import { useDoubleclickDelete } from "../hooks/useCRUD"
|
||||
|
||||
import Button from "./Button"
|
||||
|
||||
|
@ -22,12 +22,13 @@ const ItemBase: FunctionComponent<
|
|||
PropsWithChildren<{
|
||||
copyString?: string
|
||||
doDelete?: () => void
|
||||
detailsPaage?: string
|
||||
detailsPage?: string
|
||||
deleting?: boolean
|
||||
}>
|
||||
> = ({ children, doDelete, copyString, detailsPaage }) => {
|
||||
> = ({ children, doDelete, copyString, detailsPage, deleting }) => {
|
||||
const [copied, copy] = useClipboardTimeout(copyString || "")
|
||||
|
||||
const [deleting, triggerDelete] = useDoubleclickDelete(
|
||||
const [armed, triggerDelete] = useDoubleclickDelete(
|
||||
useMemo(() => doDelete ?? (() => {}), [doDelete])
|
||||
)
|
||||
return (
|
||||
|
@ -45,15 +46,16 @@ const ItemBase: FunctionComponent<
|
|||
<Button
|
||||
onClick={triggerDelete}
|
||||
className="px-2 py-1 text-sm"
|
||||
disabled={deleting}
|
||||
color="red">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{detailsPaage && (
|
||||
<Link to={`/sht/${name}`}>
|
||||
{detailsPage && (
|
||||
<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">
|
||||
<span className="my-auto">details</span>
|
||||
|
||||
|
|
|
@ -5,23 +5,17 @@ const ItemList = <T extends Record<string, unknown>, K extends keyof T>({
|
|||
items,
|
||||
Item,
|
||||
idKey,
|
||||
deleteItem,
|
||||
}: {
|
||||
items: T[]
|
||||
idKey: K
|
||||
Item: FunctionComponent<T & { doDelete: () => void }>
|
||||
deleteItem: (key: T[K]) => () => void
|
||||
Item: FunctionComponent<T>
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
role="list"
|
||||
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<Item
|
||||
{...item}
|
||||
doDelete={deleteItem(item[idKey])}
|
||||
key={item[idKey] as string}
|
||||
/>
|
||||
<Item {...item} key={item[idKey] as string} />
|
||||
))}
|
||||
</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"
|
||||
|
||||
const SessionItem: FunctionComponent<Session & { doDelete: () => void }> = ({
|
||||
doDelete,
|
||||
...session
|
||||
}) => {
|
||||
import ItemBase from "./ItemBase"
|
||||
|
||||
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 (
|
||||
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
|
||||
<ItemBase doDelete={doDelete} deleting={deleting}>
|
||||
{JSON.stringify(session)}
|
||||
<button
|
||||
onClick={doDelete}
|
||||
className="text-red-500 group-hover:text-red-600">
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,40 @@
|
|||
import { FunctionComponent } from "react"
|
||||
import { FunctionComponent, useCallback } from "react"
|
||||
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from "@heroicons/react/24/outline"
|
||||
|
||||
import { useDelete } from "../hooks/useCRUD"
|
||||
import { Short } from "../types"
|
||||
|
||||
import ItemBase from "./ItemBase"
|
||||
|
||||
const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||
name,
|
||||
url,
|
||||
doDelete,
|
||||
}) => {
|
||||
const ShortItem: FunctionComponent<Short> = ({ ...short }) => {
|
||||
const origin = location.origin
|
||||
const host = "marona.to"
|
||||
|
||||
const maxSize = 120
|
||||
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}`
|
||||
|
||||
// Handle deletion
|
||||
const [deleting, del] = useDelete()
|
||||
const doDelete = useCallback(
|
||||
() => del({ name: short.name }),
|
||||
[del, short.name]
|
||||
)
|
||||
|
||||
return (
|
||||
<ItemBase
|
||||
copyString={shortNameURL}
|
||||
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="col-span-5 md:col-span-3 my-auto flex flex-col order-1">
|
||||
<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">
|
||||
<span className="break-all">
|
||||
<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>
|
||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
|
||||
|
@ -49,8 +56,8 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
|||
</div>
|
||||
<div className="col-span-10 md:col-span-6 my-auto order-4 md:order-3">
|
||||
<a
|
||||
href={url}
|
||||
title={url}
|
||||
href={short.url}
|
||||
title={short.url}
|
||||
rel="noreferrer"
|
||||
className="flex flex-row font-normal leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
||||
<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"
|
||||
|
||||
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 (
|
||||
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
|
||||
name
|
||||
</li>
|
||||
<ItemBase copyString={token.value} doDelete={doDelete} deleting={deleting}>
|
||||
{JSON.stringify(token)}
|
||||
</ItemBase>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
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"
|
||||
|
||||
export const useOnDelete = <T extends Record<string, unknown>>(
|
||||
|
@ -20,28 +23,26 @@ export const useOnDelete = <T extends Record<string, unknown>>(
|
|||
)
|
||||
}
|
||||
|
||||
export const useDoubleclickDelete = (
|
||||
doDelete: () => void
|
||||
): [boolean, () => void] => {
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
export const useDoubleclickDelete = (doDelete: () => void) => {
|
||||
const [armed, setDeleting] = useState(false)
|
||||
useEffect(() => {
|
||||
if (deleting) {
|
||||
if (armed) {
|
||||
const timeout = setTimeout(() => {
|
||||
setDeleting(false)
|
||||
}, 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [deleting])
|
||||
}, [armed])
|
||||
|
||||
const trigger = useCallback(() => {
|
||||
if (deleting) {
|
||||
if (armed) {
|
||||
doDelete()
|
||||
} else {
|
||||
setDeleting(true)
|
||||
}
|
||||
}, [doDelete, deleting])
|
||||
}, [doDelete, armed])
|
||||
|
||||
return [deleting, trigger]
|
||||
return [armed, trigger] as const
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
Form,
|
||||
LoaderFunction,
|
||||
useActionData,
|
||||
useNavigation,
|
||||
} from "react-router-dom"
|
||||
ChangeEvent,
|
||||
FormEvent,
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { useNavigation, useSubmit, useActionData } from "react-router-dom"
|
||||
|
||||
import Button from "../components/Button"
|
||||
import Header from "../components/Header"
|
||||
import ItemList from "../components/ItemList"
|
||||
import ShortItem from "../components/ShortItem"
|
||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
||||
import { Short } from "../types"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
import { crudAction } from "../util/action"
|
||||
import { FetchAPIResult } from "../util/fetchAPI"
|
||||
|
||||
type ActionResponse =
|
||||
| {
|
||||
short: Short
|
||||
import { action as shortAction } from "./Shorts"
|
||||
|
||||
/**
|
||||
* 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 actionData = useActionData() as
|
||||
| FetchAPIResult<Short | string>
|
||||
| undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (onCreate && actionData && actionData.ok) {
|
||||
const data = actionData.data
|
||||
if (typeof data !== "string") {
|
||||
// Creating
|
||||
onCreate(data)
|
||||
}
|
||||
| { error: string }
|
||||
}
|
||||
}, [actionData, onCreate])
|
||||
|
||||
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 form = useRef<HTMLFormElement>(null)
|
||||
const navigation = useNavigation()
|
||||
const isShortening = navigation.formData?.get("url") != null
|
||||
const {
|
||||
shortening,
|
||||
url,
|
||||
name,
|
||||
error,
|
||||
handleNameChange,
|
||||
handleURLChange,
|
||||
handleSubmit,
|
||||
} = useShortForm()
|
||||
|
||||
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(() => {
|
||||
if (actionData && "short" in actionData) {
|
||||
setShorts((shorts) => [actionData.short, ...shorts])
|
||||
// If success, also reset the form and remove focus
|
||||
form.current?.reset()
|
||||
setName("")
|
||||
for (const input of form.current?.elements || []) {
|
||||
;(input as HTMLElement).blur()
|
||||
}
|
||||
}
|
||||
}, [actionData])
|
||||
|
||||
const deleteItem = useOnDelete("/shorts", "name", setShorts)
|
||||
const recentShorts = useRecentShorts()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="" />
|
||||
<Form
|
||||
method="post"
|
||||
replace
|
||||
ref={form}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
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">
|
||||
<span className="text-2xl xl:text-4xl font-medium">
|
||||
|
@ -80,6 +176,8 @@ export const Component: FunctionComponent = () => {
|
|||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
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"
|
||||
placeholder="https://example.com/long/url"
|
||||
/>
|
||||
|
@ -98,67 +196,30 @@ export const Component: FunctionComponent = () => {
|
|||
name="name"
|
||||
minLength={4}
|
||||
maxLength={20}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
autoComplete="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"
|
||||
placeholder="my-short-link"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{actionData && "error" in actionData ? (
|
||||
<p className="text-red-500 text-center font-medium">
|
||||
{actionData.error}
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="text-red-500 text-center font-medium">{error}</p>
|
||||
) : null}
|
||||
<Button type="submit" className="py-3 text-2xl">
|
||||
{isShortening ? "Shortening..." : "Shorten it"}
|
||||
{shortening ? "Shortening..." : "Shorten it"}
|
||||
</Button>
|
||||
</Form>
|
||||
</form>
|
||||
<div className="mt-10">
|
||||
<ItemList
|
||||
items={shorts}
|
||||
Item={ShortItem}
|
||||
idKey="name"
|
||||
deleteItem={deleteItem}
|
||||
/>
|
||||
<ItemList items={recentShorts} Item={ShortItem} idKey="name" />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const action: LoaderFunction = async ({
|
||||
request,
|
||||
}): Promise<ActionResponse> => {
|
||||
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 }
|
||||
}
|
||||
export const action = crudAction({
|
||||
POST: shortAction.handlers.POST,
|
||||
DELETE: shortAction.handlers.DELETE,
|
||||
})
|
||||
|
|
|
@ -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 ItemList from "../components/ItemList"
|
||||
import NoItems from "../components/NoItems"
|
||||
import SessionItem from "../components/SessionItem"
|
||||
import { protectedLoader } from "../hooks/useAuth"
|
||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||
import { Session } from "../types"
|
||||
import { crudAction } from "../util/action"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
|
||||
export function Component() {
|
||||
const [data, setData] = useSortedLoadedItems<Session>(
|
||||
useMemo(() => (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 [items] = useSortedLoadedItems<Session>(
|
||||
useCallback((a, b) => b.lastActivity.localeCompare(a.lastActivity), [])
|
||||
)
|
||||
|
||||
const Sessions: FunctionComponent = () => {
|
||||
return (
|
||||
<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 <ItemList items={items} Item={SessionItem} idKey="id" />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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")
|
||||
}
|
||||
|
||||
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"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FunctionComponent, useMemo } from "react"
|
||||
import { FunctionComponent, useCallback } from "react"
|
||||
|
||||
import { LoaderFunction, redirect } from "react-router-dom"
|
||||
|
||||
|
@ -6,27 +6,18 @@ import Header from "../components/Header"
|
|||
import ItemList from "../components/ItemList"
|
||||
import ShortItem from "../components/ShortItem"
|
||||
import { protectedLoader } from "../hooks/useAuth"
|
||||
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||
import { Short } from "../types"
|
||||
import { crudAction } from "../util/action"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
|
||||
export function Component() {
|
||||
const [shorts, setShorts] = useSortedLoadedItems<Short>(
|
||||
useMemo(() => (a, b) => a.name.localeCompare(b.name), [])
|
||||
const [items] = useSortedLoadedItems<Short>(
|
||||
useCallback((a, b) => a.name.localeCompare(b.name), [])
|
||||
)
|
||||
|
||||
const deleteItem = useOnDelete("/shorts", "name", setShorts)
|
||||
|
||||
const Shorts: FunctionComponent = () => {
|
||||
return (
|
||||
<ItemList
|
||||
items={shorts}
|
||||
Item={ShortItem}
|
||||
idKey="name"
|
||||
deleteItem={deleteItem}
|
||||
/>
|
||||
)
|
||||
return <ItemList items={items} Item={ShortItem} idKey="name" />
|
||||
}
|
||||
const NoShorts = () => {
|
||||
return (
|
||||
|
@ -37,7 +28,7 @@ export function Component() {
|
|||
return (
|
||||
<>
|
||||
<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")
|
||||
}
|
||||
|
||||
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"
|
||||
|
|
|
@ -1,34 +1,30 @@
|
|||
import { FunctionComponent, useMemo } from "react"
|
||||
import { FunctionComponent, useCallback } from "react"
|
||||
|
||||
import { PlusIcon } from "@heroicons/react/24/outline"
|
||||
import { LoaderFunction, redirect } from "react-router-dom"
|
||||
|
||||
import Button from "../components/Button"
|
||||
import Header from "../components/Header"
|
||||
import ItemList from "../components/ItemList"
|
||||
import TokenItem from "../components/TokenItem"
|
||||
import { protectedLoader } from "../hooks/useAuth"
|
||||
import { useOnCreate, useOnDelete } from "../hooks/useOnUpdateItem"
|
||||
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||
import { useCreate } from "../hooks/useCRUD"
|
||||
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
|
||||
import { Token } from "../types"
|
||||
import { crudAction } from "../util/action"
|
||||
import fetchAPI from "../util/fetchAPI"
|
||||
|
||||
export function Component() {
|
||||
const [data, setData] = useSortedLoadedItems<Token>(
|
||||
useMemo(() => (a, b) => b.createdAt.localeCompare(a.createdAt), [])
|
||||
const [items] = useSortedLoadedItems<Token>(
|
||||
useCallback((a, b) => b.createdAt.localeCompare(a.createdAt), [])
|
||||
)
|
||||
|
||||
const deleteItem = useOnDelete("/tokens", "id", setData)
|
||||
const [creating, createItem] = useOnCreate("/tokens", setData)
|
||||
// Handle creation
|
||||
const [creating, create] = useCreate()
|
||||
const onClickCreate = useCallback(() => create(), [create])
|
||||
|
||||
const Tokens: FunctionComponent = () => {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <ItemList items={items} Item={TokenItem} idKey="id" />
|
||||
}
|
||||
const NoTokens = () => {
|
||||
return (
|
||||
|
@ -41,12 +37,12 @@ export function Component() {
|
|||
<Header title="Tokens" />
|
||||
<Button
|
||||
disabled={creating}
|
||||
onClick={() => createItem()}
|
||||
onClick={onClickCreate}
|
||||
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" />
|
||||
<span>New token</span>
|
||||
</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)
|
||||
if (resp) return resp
|
||||
|
||||
// const data = await fetchAPI<Token[]>("/tokens")
|
||||
const data = { ok: true, data: [] }
|
||||
const data = await fetchAPI<Token[]>("/tokens")
|
||||
if (data.ok) {
|
||||
return data.data
|
||||
}
|
||||
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"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export type GenericItem = Record<string, unknown>
|
||||
|
||||
export type User = {
|
||||
username: string
|
||||
}
|
||||
|
@ -9,7 +11,6 @@ export type Short = {
|
|||
|
||||
export type Session = {
|
||||
id: string
|
||||
username: string
|
||||
ip: string
|
||||
userAgent: string
|
||||
lastActivity: string
|
||||
|
@ -19,6 +20,7 @@ export type Session = {
|
|||
|
||||
export type Token = {
|
||||
id: string
|
||||
name: string
|
||||
value: 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
|
||||
}
|
||||
|
||||
type Result<T> =
|
||||
export type FetchAPIResult<T> =
|
||||
| {
|
||||
data: T | string
|
||||
data: T
|
||||
ok: true
|
||||
}
|
||||
| {
|
||||
data: string
|
||||
error: string
|
||||
ok: false
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ type Result<T> =
|
|||
export default async function <T>(
|
||||
path: string,
|
||||
args: Parameters<typeof fetch>[1] = {}
|
||||
): Promise<Result<T>> {
|
||||
): Promise<FetchAPIResult<T>> {
|
||||
if (import.meta.env.DEV) {
|
||||
args.credentials = "include"
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export default async function <T>(
|
|||
console.error(e)
|
||||
|
||||
return {
|
||||
data: (e as Error).message,
|
||||
error: (e as Error).message,
|
||||
ok: false,
|
||||
}
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ export default async function <T>(
|
|||
}
|
||||
|
||||
return {
|
||||
data:
|
||||
error:
|
||||
(dataOrString as ErrorResponse)?.error ||
|
||||
(dataOrString as ErrorResponse)?.status ||
|
||||
response.statusText,
|
||||
|
|
|
@ -34,6 +34,12 @@ var (
|
|||
ErrTokenExists = errors.New("token already exists")
|
||||
// ErrTokenMissing
|
||||
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 {
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"git.maronato.dev/maronato/goshort/internal/server"
|
||||
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
|
||||
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"
|
||||
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
@ -18,13 +19,15 @@ import (
|
|||
type APIHandler struct {
|
||||
shorts *shortservice.ShortService
|
||||
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{
|
||||
shorts: shorts,
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -49,8 +52,14 @@ func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Delete all user's sessions
|
||||
err := servermiddleware.DeleteAllUserSessions(ctx, user)
|
||||
if err != nil {
|
||||
server.RenderServerError(w, r, err)
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
err := h.users.DeleteUser(ctx, user)
|
||||
err = h.users.DeleteUser(ctx, user)
|
||||
if err != nil {
|
||||
server.RenderServerError(w, r, err)
|
||||
|
||||
|
@ -70,20 +79,20 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
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)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 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 errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
||||
// 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) {
|
||||
// If the request was invalid, return bad request
|
||||
server.RenderBadRequest(w, r, err)
|
||||
|
@ -147,12 +156,12 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// Create user
|
||||
err := h.users.CreateUser(ctx, user)
|
||||
newUser, err := h.users.CreateUser(ctx, user)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
|
||||
server.RenderBadRequest(w, r, err)
|
||||
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
|
||||
server.RenderForbidden(w, r, err)
|
||||
server.RenderForbidden(w, r)
|
||||
} else {
|
||||
server.RenderServerError(w, r, err)
|
||||
}
|
||||
|
@ -162,7 +171,7 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Render the response
|
||||
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) {
|
||||
|
@ -187,7 +196,7 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
|||
short.User = user
|
||||
|
||||
// Shorten URL
|
||||
short, err := h.shorts.Shorten(ctx, short)
|
||||
newShort, err := h.shorts.Shorten(ctx, short)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
||||
server.RenderBadRequest(w, r, err)
|
||||
|
@ -200,7 +209,7 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Render the response
|
||||
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) {
|
||||
|
@ -293,7 +302,7 @@ func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
|||
err := servermiddleware.DeleteUserSession(ctx, user, sessionToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, errs.ErrSessionDoesNotExist) {
|
||||
server.RenderNotFound(w, r, err)
|
||||
server.RenderNotFound(w, r)
|
||||
} else {
|
||||
server.RenderServerError(w, r, err)
|
||||
}
|
||||
|
@ -302,7 +311,106 @@ func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// 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,
|
||||
|
@ -321,7 +429,7 @@ func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (
|
|||
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
|
||||
// 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) {
|
||||
|
@ -335,7 +443,7 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
|
|||
if err != nil {
|
||||
// If the short doesn't exist or is invalid, return not found
|
||||
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
||||
server.RenderNotFound(w, r, err)
|
||||
server.RenderNotFound(w, r)
|
||||
} else {
|
||||
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,
|
||||
// return forbidden.
|
||||
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 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
|
||||
r.Get("/sessions", h.ListSessions)
|
||||
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
|
||||
|
|
|
@ -25,15 +25,20 @@ func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func ErrGeneric(err error, status int) render.Renderer {
|
||||
func ErrBasic(status int) *ErrResponse {
|
||||
return &ErrResponse{
|
||||
Err: err,
|
||||
HTTPStatusCode: 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 {
|
||||
return ErrGeneric(err, http.StatusBadRequest)
|
||||
}
|
||||
|
@ -42,16 +47,16 @@ func ErrServerError(err error) render.Renderer {
|
|||
return ErrGeneric(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func ErrNotFound(err error) render.Renderer {
|
||||
return ErrGeneric(err, http.StatusNotFound)
|
||||
func ErrNotFound() render.Renderer {
|
||||
return ErrBasic(http.StatusNotFound)
|
||||
}
|
||||
|
||||
func ErrUnauthorized(err error) render.Renderer {
|
||||
return ErrGeneric(err, http.StatusUnauthorized)
|
||||
func ErrUnauthorized() render.Renderer {
|
||||
return ErrBasic(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func ErrForbidden(err error) render.Renderer {
|
||||
return ErrGeneric(err, http.StatusForbidden)
|
||||
func ErrForbidden() render.Renderer {
|
||||
return ErrBasic(http.StatusForbidden)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func RenderNotFound(w http.ResponseWriter, r *http.Request, err error) {
|
||||
RenderRender(w, r, ErrNotFound(err))
|
||||
func RenderNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
RenderRender(w, r, ErrNotFound())
|
||||
}
|
||||
|
||||
func RenderUnauthorized(w http.ResponseWriter, r *http.Request, err error) {
|
||||
RenderRender(w, r, ErrUnauthorized(err))
|
||||
func RenderUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
RenderRender(w, r, ErrUnauthorized())
|
||||
}
|
||||
|
||||
func RenderForbidden(w http.ResponseWriter, r *http.Request, err error) {
|
||||
RenderRender(w, r, ErrForbidden(err))
|
||||
func RenderForbidden(w http.ResponseWriter, r *http.Request) {
|
||||
RenderRender(w, r, ErrForbidden())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
|
||||
|
|
|
@ -82,12 +82,12 @@ func (s *ShortService) Shorten(ctx context.Context, short *models.Short) (*model
|
|||
}
|
||||
|
||||
// Save the short in storage
|
||||
err = s.db.CreateShort(ctx, short)
|
||||
newShort, err := s.db.CreateShort(ctx, short)
|
||||
if err != nil {
|
||||
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) {
|
||||
|
|
|
@ -5,9 +5,11 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||
"git.maronato.dev/maronato/goshort/internal/storage"
|
||||
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
||||
shortutil "git.maronato.dev/maronato/goshort/internal/util/short"
|
||||
tokenutil "git.maronato.dev/maronato/goshort/internal/util/token"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -40,10 +42,10 @@ func NewTokenService(db storage.Storage) *TokenService {
|
|||
func (s *TokenService) FindToken(ctx context.Context, value string) (*models.Token, error) {
|
||||
// Check if the token has the prefix
|
||||
if !strings.HasPrefix(value, TokenPrefix) {
|
||||
return &models.Token{}, fmt.Errorf("invalid token")
|
||||
return &models.Token{}, errs.ErrInvalidToken
|
||||
}
|
||||
if len(value) != TokenLength+len(TokenPrefix) {
|
||||
return &models.Token{}, fmt.Errorf("invalid token")
|
||||
return &models.Token{}, errs.ErrInvalidToken
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Check if the ID is valid
|
||||
if len(id) != TokenIDLength {
|
||||
return &models.Token{}, fmt.Errorf("invalid ID")
|
||||
return &models.Token{}, errs.ErrInvalidTokenID
|
||||
}
|
||||
|
||||
// Get the token from storage
|
||||
|
@ -71,23 +73,34 @@ func (s *TokenService) FindTokenByID(ctx context.Context, id string) (*models.To
|
|||
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.
|
||||
func (s *TokenService) CreateToken(ctx context.Context, user *models.User) (*models.Token, error) {
|
||||
// Generate a new token
|
||||
id := shortutil.GenerateRandomShort(TokenIDLength)
|
||||
token := &models.Token{
|
||||
ID: shortutil.GenerateRandomShort(TokenIDLength),
|
||||
Name: user.Username + "'s token",
|
||||
Value: TokenPrefix + shortutil.GenerateRandomShort(TokenLength),
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("%s's token #%s", user.Username, id[:5]),
|
||||
Value: TokenPrefix + tokenutil.GenerateSecureToken(TokenLength),
|
||||
User: user,
|
||||
}
|
||||
|
||||
// Create the token in storage
|
||||
err := s.db.CreateToken(ctx, token)
|
||||
newToken, err := s.db.CreateToken(ctx, token)
|
||||
if err != nil {
|
||||
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.
|
||||
|
@ -100,3 +113,18 @@ func (s *TokenService) DeleteToken(ctx context.Context, token *models.Token) err
|
|||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
if s.disableRegistration {
|
||||
return errs.ErrRegistrationDisabled
|
||||
return &models.User{}, errs.ErrRegistrationDisabled
|
||||
}
|
||||
|
||||
// Check if the user is valid
|
||||
err := UserIsValid(user)
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.maronato.dev/maronato/goshort/internal/errs"
|
||||
"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
|
||||
}
|
||||
|
||||
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()
|
||||
defer s.shortMu.Unlock()
|
||||
|
||||
_, ok := s.shortMap[short.Name]
|
||||
if ok {
|
||||
return errs.ErrShortExists
|
||||
return &models.Short{}, errs.ErrShortExists
|
||||
}
|
||||
|
||||
s.shortMap[short.Name] = short
|
||||
|
||||
return nil
|
||||
return short, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
_, ok := s.userMap[user.Username]
|
||||
if ok {
|
||||
return errs.ErrUserExists
|
||||
return &models.User{}, errs.ErrUserExists
|
||||
}
|
||||
|
||||
s.userMap[user.Username] = user
|
||||
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
|
@ -181,23 +196,25 @@ func (s *MemoryStorage) ListTokens(ctx context.Context, user *models.User) ([]*m
|
|||
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()
|
||||
defer s.tokenMu.Unlock()
|
||||
|
||||
_, ok := s.tokenMap[token.Value]
|
||||
if ok {
|
||||
return errs.ErrTokenExists
|
||||
return &models.Token{}, errs.ErrTokenExists
|
||||
}
|
||||
_, ok = s.tokenIDMap[token.ID]
|
||||
if ok {
|
||||
return errs.ErrTokenExists
|
||||
return &models.Token{}, errs.ErrTokenExists
|
||||
}
|
||||
|
||||
token.CreatedAt = time.Now()
|
||||
|
||||
s.tokenMap[token.Value] = token
|
||||
s.tokenIDMap[token.ID] = token
|
||||
|
||||
return nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStorage) DeleteToken(ctx context.Context, token *models.Token) error {
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Token struct {
|
||||
// ID is the unique identifier of the token.
|
||||
// ID is the unique identifier of the token
|
||||
ID string `json:"id"`
|
||||
// Name is the user-friendly name of the token.
|
||||
Name string `json:"name"`
|
||||
// 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 *User `json:"-"`
|
||||
|
|
|
@ -14,7 +14,7 @@ type Storage interface {
|
|||
// FindShorts finds all shorts in the storage that belong to a user.
|
||||
ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error)
|
||||
// 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(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(ctx context.Context, username string) (*models.User, error)
|
||||
// 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(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(ctx context.Context, user *models.User) ([]*models.Token, error)
|
||||
// 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(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