From 948a21971286e4255e03ccf04b5d8da77bd93e35 Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Mon, 21 Aug 2023 18:08:41 -0300 Subject: [PATCH] nice changes --- .vscode/settings.json | 3 + cmd/dev/dev.go | 2 +- cmd/serve/serve.go | 2 +- frontend/src/components/ItemBase.tsx | 16 +- frontend/src/components/ItemList.tsx | 10 +- frontend/src/components/NoItems.tsx | 11 + frontend/src/components/SessionItem.tsx | 34 ++- frontend/src/components/ShortItem.tsx | 29 +- frontend/src/components/TokenItem.tsx | 16 +- .../{useOnUpdateItem.tsx => useCRUD.tsx} | 75 +++++- frontend/src/hooks/useLoaderItems.tsx | 45 ++++ frontend/src/hooks/useSortedILoadedtems.tsx | 22 -- frontend/src/pages/Index.tsx | 255 +++++++++++------- frontend/src/pages/Sessions.tsx | 62 ++--- frontend/src/pages/Shorts.tsx | 50 ++-- frontend/src/pages/Tokens.tsx | 63 +++-- frontend/src/types.ts | 4 +- frontend/src/util/action.ts | 43 +++ frontend/src/util/fetchAPI.ts | 12 +- internal/errs/errors.go | 6 + internal/server/api/handler.go | 179 ++++++++++-- internal/server/api/router.go | 6 + internal/server/errors.go | 35 +-- internal/server/middleware/auth.go | 17 ++ internal/service/short/shortservice.go | 4 +- internal/service/token/tokenservice.go | 44 ++- internal/service/user/userservice.go | 12 +- internal/storage/memory/memory.go | 37 ++- internal/storage/models/token.go | 8 +- internal/storage/storage.go | 8 +- internal/util/token/generator.go | 14 + 31 files changed, 806 insertions(+), 318 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 frontend/src/components/NoItems.tsx rename frontend/src/hooks/{useOnUpdateItem.tsx => useCRUD.tsx} (52%) create mode 100644 frontend/src/hooks/useLoaderItems.tsx delete mode 100644 frontend/src/hooks/useSortedILoadedtems.tsx create mode 100644 frontend/src/util/action.ts create mode 100644 internal/util/token/generator.go diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ce072c8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.tabSize": 2 +} diff --git a/cmd/dev/dev.go b/cmd/dev/dev.go index 88a6217..a0a46f8 100644 --- a/cmd/dev/dev.go +++ b/cmd/dev/dev.go @@ -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() diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 2d97f64..9106c6a 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -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() diff --git a/frontend/src/components/ItemBase.tsx b/frontend/src/components/ItemBase.tsx index 86a6dc3..011b5d6 100644 --- a/frontend/src/components/ItemBase.tsx +++ b/frontend/src/components/ItemBase.tsx @@ -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< )}
- {detailsPaage && ( - + {detailsPage && ( +
details diff --git a/frontend/src/components/ItemList.tsx b/frontend/src/components/ItemList.tsx index 2465df5..6ab6f53 100644 --- a/frontend/src/components/ItemList.tsx +++ b/frontend/src/components/ItemList.tsx @@ -5,23 +5,17 @@ const ItemList = , K extends keyof T>({ items, Item, idKey, - deleteItem, }: { items: T[] idKey: K - Item: FunctionComponent void }> - deleteItem: (key: T[K]) => () => void + Item: FunctionComponent }) => { return (
    {items.map((item) => ( - + ))}
) diff --git a/frontend/src/components/NoItems.tsx b/frontend/src/components/NoItems.tsx new file mode 100644 index 0000000..a79d92e --- /dev/null +++ b/frontend/src/components/NoItems.tsx @@ -0,0 +1,11 @@ +import { FunctionComponent } from "react" + +const NoItems: FunctionComponent<{ type: string }> = ({ type }) => { + return ( +
+ {`No ${type}s yet`} +
+ ) +} + +export default NoItems diff --git a/frontend/src/components/SessionItem.tsx b/frontend/src/components/SessionItem.tsx index a947e71..48dfc4b 100644 --- a/frontend/src/components/SessionItem.tsx +++ b/frontend/src/components/SessionItem.tsx @@ -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 void }> = ({ - doDelete, - ...session -}) => { +import ItemBase from "./ItemBase" + +const SessionItem: FunctionComponent = ({ ...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 ( -
  • + {JSON.stringify(session)} - -
  • + ) } diff --git a/frontend/src/components/ShortItem.tsx b/frontend/src/components/ShortItem.tsx index d82efaf..8848107 100644 --- a/frontend/src/components/ShortItem.tsx +++ b/frontend/src/components/ShortItem.tsx @@ -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 void }> = ({ - name, - url, - doDelete, -}) => { +const ShortItem: FunctionComponent = ({ ...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 ( + deleting={deleting} + detailsPage={`/sht/${short.name}`}>
    diff --git a/frontend/src/components/TokenItem.tsx b/frontend/src/components/TokenItem.tsx index 7654609..b3b5b05 100644 --- a/frontend/src/components/TokenItem.tsx +++ b/frontend/src/components/TokenItem.tsx @@ -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 void }> = () => { +import ItemBase from "./ItemBase" + +const TokenItem: FunctionComponent = ({ ...token }) => { + const [deleting, del] = useDelete() + const doDelete = useCallback(() => del({ id: token.id }), [del, token.id]) + return ( -
  • - name -
  • + + {JSON.stringify(token)} + ) } diff --git a/frontend/src/hooks/useOnUpdateItem.tsx b/frontend/src/hooks/useCRUD.tsx similarity index 52% rename from frontend/src/hooks/useOnUpdateItem.tsx rename to frontend/src/hooks/useCRUD.tsx index e147ed0..19b51f8 100644 --- a/frontend/src/hooks/useOnUpdateItem.tsx +++ b/frontend/src/hooks/useCRUD.tsx @@ -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 = >( @@ -20,28 +23,26 @@ export const useOnDelete = >( ) } -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 = >( @@ -71,3 +72,59 @@ export const useOnCreate = >( return [creating, create] as const } + +export const useCreate = ( + 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 = ( + 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 +} diff --git a/frontend/src/hooks/useLoaderItems.tsx b/frontend/src/hooks/useLoaderItems.tsx new file mode 100644 index 0000000..4481180 --- /dev/null +++ b/frontend/src/hooks/useLoaderItems.tsx @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useMemo, useState } from "react" + +import { useLoaderData } from "react-router-dom" + +type Item = Record + +export const useLoadedItems = () => { + const sourceDefault = useMemo(() => [], []) + const items = (useLoaderData() ?? sourceDefault) as T[] + + return items +} + +export const useSortedItems = ( + 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 = ( + sort: (a: T, b: T) => number +) => { + const items = useLoadedItems() + return useSortedItems(items, sort) +} diff --git a/frontend/src/hooks/useSortedILoadedtems.tsx b/frontend/src/hooks/useSortedILoadedtems.tsx deleted file mode 100644 index b25bb09..0000000 --- a/frontend/src/hooks/useSortedILoadedtems.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useMemo, useState } from "react" - -import { useLoaderData } from "react-router-dom" - -const useSortedLoadedItems = ( - 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 diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index f690040..9ce9c75 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -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 - } - | { error: string } +import { action as shortAction } from "./Shorts" -export const Component: FunctionComponent = () => { - const form = useRef(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 isShortening = navigation.formData?.get("url") != null + const actionData = useActionData() as + | FetchAPIResult + | undefined - const [name, setName] = useState("") - const handleNameChange = (e: React.ChangeEvent) => { - const value = e.target.value.replace(/[^a-z0-9-_]/gi, "") - setName(value) - } - - const actionData = useActionData() as ActionResponse | undefined - - const [shorts, setShorts] = useState( - 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() + if (onCreate && actionData && actionData.ok) { + const data = actionData.data + if (typeof data !== "string") { + // Creating + onCreate(data) } } - }, [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) => { + setURL(e.target.value) + }, []) + const handleNameChange = useCallback((e: ChangeEvent) => { + setName(e.target.value.replace(/[^a-z0-9-_]/gi, "")) + }, []) + + const submit = useSubmit() + const handleSubmit = useCallback( + (e: FormEvent) => { + 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( + 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 ( <>
    -
    - {actionData && "error" in actionData ? ( -

    - {actionData.error} -

    + {error ? ( +

    {error}

    ) : null} - +
    - +
    ) } -export const action: LoaderFunction = async ({ - request, -}): Promise => { - 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("/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, +}) diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx index f626641..be9d0bb 100644 --- a/frontend/src/pages/Sessions.tsx +++ b/frontend/src/pages/Sessions.tsx @@ -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( - 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( + useCallback((a, b) => b.lastActivity.localeCompare(a.lastActivity), []) ) const Sessions: FunctionComponent = () => { - return ( -
      - {data.map((session) => ( - - ))} -
    - ) - } - const NoSessions = () => { - return ( -
    No sessions yet
    - ) + return } return ( <>
    - {data.length > 0 ? : } + {items.length > 0 ? : } ) } @@ -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(`/sessions/${id}`, { method: "DELETE" }) + }, +}) + Component.displayName = "SessionsPage" diff --git a/frontend/src/pages/Shorts.tsx b/frontend/src/pages/Shorts.tsx index a7d9afc..aee8ce2 100644 --- a/frontend/src/pages/Shorts.tsx +++ b/frontend/src/pages/Shorts.tsx @@ -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( - useMemo(() => (a, b) => a.name.localeCompare(b.name), []) + const [items] = useSortedLoadedItems( + useCallback((a, b) => a.name.localeCompare(b.name), []) ) - const deleteItem = useOnDelete("/shorts", "name", setShorts) - const Shorts: FunctionComponent = () => { - return ( - - ) + return } const NoShorts = () => { return ( @@ -37,7 +28,7 @@ export function Component() { return ( <>
    - {shorts.length > 0 ? : } + {items.length > 0 ? : } ) } @@ -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("/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(`/shorts/${name}`, { + method: "DELETE", + }) + }, +}) + Component.displayName = "ShortsPage" diff --git a/frontend/src/pages/Tokens.tsx b/frontend/src/pages/Tokens.tsx index 5c6c500..4c41592 100644 --- a/frontend/src/pages/Tokens.tsx +++ b/frontend/src/pages/Tokens.tsx @@ -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( - useMemo(() => (a, b) => b.createdAt.localeCompare(a.createdAt), []) + const [items] = useSortedLoadedItems( + 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 ( -
      - {data.map((item) => ( - - ))} -
    - ) + return } const NoTokens = () => { return ( @@ -41,12 +37,12 @@ export function Component() {
    - {data.length > 0 ? : } + {items.length > 0 ? : } ) } @@ -55,12 +51,39 @@ export const loader: LoaderFunction = async (args) => { const resp = await protectedLoader(args) if (resp) return resp - // const data = await fetchAPI("/tokens") - const data = { ok: true, data: [] } + const data = await fetchAPI("/tokens") if (data.ok) { return data.data } return redirect("/lgo") } +export const action = crudAction({ + POST: () => fetchAPI("/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(`/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(`/tokens/${id}`, { + method: "DELETE", + }) + }, +}) + Component.displayName = "TokensPage" diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c279311..07dbbfe 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,3 +1,5 @@ +export type GenericItem = Record + 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 } diff --git a/frontend/src/util/action.ts b/frontend/src/util/action.ts new file mode 100644 index 0000000..206c989 --- /dev/null +++ b/frontend/src/util/action.ts @@ -0,0 +1,43 @@ +import { FormMethod, LoaderFunction } from "react-router-dom" + +import { GenericItem } from "../types" + +import { FetchAPIResult } from "./fetchAPI" + +export type ActionHandler = ( + formData: FormData +) => Promise> +export type ActionHandlers = { + [method in Uppercase]?: ActionHandler +} + +type CRUDActionFunc = LoaderFunction & { + handlers: ActionHandlers +} + +export function crudAction(handlers: ActionHandlers) { + const func: CRUDActionFunc = async ({ request }) => { + // Get the method from the request + const method = request.method.toUpperCase() as Uppercase + + // 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 +} diff --git a/frontend/src/util/fetchAPI.ts b/frontend/src/util/fetchAPI.ts index f281665..f88fefb 100644 --- a/frontend/src/util/fetchAPI.ts +++ b/frontend/src/util/fetchAPI.ts @@ -3,13 +3,13 @@ type ErrorResponse = { error: string } -type Result = +export type FetchAPIResult = | { - data: T | string + data: T ok: true } | { - data: string + error: string ok: false } @@ -17,7 +17,7 @@ type Result = export default async function ( path: string, args: Parameters[1] = {} -): Promise> { +): Promise> { if (import.meta.env.DEV) { args.credentials = "include" } @@ -29,7 +29,7 @@ export default async function ( console.error(e) return { - data: (e as Error).message, + error: (e as Error).message, ok: false, } } @@ -45,7 +45,7 @@ export default async function ( } return { - data: + error: (dataOrString as ErrorResponse)?.error || (dataOrString as ErrorResponse)?.status || response.statusText, diff --git a/internal/errs/errors.go b/internal/errs/errors.go index d3a6849..1bc825a 100644 --- a/internal/errs/errors.go +++ b/internal/errs/errors.go @@ -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 { diff --git a/internal/server/api/handler.go b/internal/server/api/handler.go index 5d01d93..425170d 100644 --- a/internal/server/api/handler.go +++ b/internal/server/api/handler.go @@ -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 +} diff --git a/internal/server/api/router.go b/internal/server/api/router.go index fd6f9e1..84fe6f5 100644 --- a/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -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 diff --git a/internal/server/errors.go b/internal/server/errors.go index 6f7e1e2..b4b27ac 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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) { diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index 6d9f3be..af83600 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -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() diff --git a/internal/service/short/shortservice.go b/internal/service/short/shortservice.go index bbcf0ed..1f2517e 100644 --- a/internal/service/short/shortservice.go +++ b/internal/service/short/shortservice.go @@ -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) { diff --git a/internal/service/token/tokenservice.go b/internal/service/token/tokenservice.go index d0dde4e..59cbfc0 100644 --- a/internal/service/token/tokenservice.go +++ b/internal/service/token/tokenservice.go @@ -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 +} diff --git a/internal/service/user/userservice.go b/internal/service/user/userservice.go index 3351be0..486f7a6 100644 --- a/internal/service/user/userservice.go +++ b/internal/service/user/userservice.go @@ -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 { diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 05ea9ac..de32436 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -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 { diff --git a/internal/storage/models/token.go b/internal/storage/models/token.go index adab7b5..cab0855 100644 --- a/internal/storage/models/token.go +++ b/internal/storage/models/token.go @@ -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:"-"` diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 34aa367..71d04dd 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 } diff --git a/internal/util/token/generator.go b/internal/util/token/generator.go new file mode 100644 index 0000000..44de2ed --- /dev/null +++ b/internal/util/token/generator.go @@ -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) +}