nice changes

This commit is contained in:
Gustavo Maronato 2023-08-21 18:08:41 -03:00
parent 9b07c526a5
commit 948a219712
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
31 changed files with 806 additions and 318 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

View File

@ -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()

View File

@ -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()

View File

@ -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>

View File

@ -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>
)

View File

@ -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

View File

@ -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>
)
}

View File

@ -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">

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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

View File

@ -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<HTMLFormElement>(null)
/**
* useSubmitActions provides callbacks that are called when a short is created,
* deleted, or an error occurs.
*/
const useSubmitActions = ({
onCreate,
onDelete,
onError,
onIdle,
}: {
onCreate?: (short: Short) => void
onDelete?: (name: string) => void
onError?: (error: string) => void
onIdle?: () => void
}) => {
const navigation = useNavigation()
const isShortening = navigation.formData?.get("url") != null
const actionData = useActionData() as
| FetchAPIResult<Short | string>
| undefined
const [name, setName] = useState("")
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^a-z0-9-_]/gi, "")
setName(value)
}
const actionData = useActionData() as ActionResponse | undefined
const [shorts, setShorts] = useState<Short[]>(
useMemo(
() => [
{
name: "test",
url: "https://example.com",
},
],
[]
)
)
useEffect(() => {
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<HTMLInputElement>) => {
setURL(e.target.value)
}, [])
const handleNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value.replace(/[^a-z0-9-_]/gi, ""))
}, [])
const submit = useSubmit()
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setShortening(true)
submit({ url, name }, { method: "POST", replace: true })
},
[submit, url, name]
)
// Reset form on create, set error on error
useSubmitActions({
onCreate: useCallback(() => {
setName("")
setURL("")
}, []),
onError: setError,
onIdle: useCallback(() => {
setShortening(false)
}, []),
})
return {
shortening,
url,
name,
error,
handleURLChange,
handleNameChange,
handleSubmit,
}
}
const useRecentShorts = () => {
const [recentShorts, setRecentShorts] = useState<Short[]>(
useMemo(() => [], [])
)
useSubmitActions({
onCreate: useCallback((short: Short) => {
setRecentShorts((prev) => [short, ...prev])
}, []),
onDelete: useCallback((name: string) => {
setRecentShorts((prev) => prev.filter((short) => short.name !== name))
}, []),
})
return recentShorts
}
export const Component: FunctionComponent = () => {
const {
shortening,
url,
name,
error,
handleNameChange,
handleURLChange,
handleSubmit,
} = useShortForm()
const recentShorts = useRecentShorts()
return (
<>
<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,
})

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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) {

View File

@ -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()

View File

@ -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) {

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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:"-"`

View File

@ -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
}

View File

@ -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)
}