nice changes

This commit is contained in:
Gustavo Maronato 2023-08-21 01:19:10 -03:00
parent 8a1a511a9b
commit 7052420b79
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
38 changed files with 1384 additions and 361 deletions

View File

@ -16,10 +16,11 @@ import (
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/peterbourgon/ff/v3/ffcli"
"golang.org/x/sync/errgroup"
)
@ -76,6 +77,7 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
storage := shared.InitStorage(cfg)
shortService := shortservice.NewShortService(storage)
userService := userservice.NewUserService(cfg, storage)
tokenService := tokenservice.NewTokenService(storage)
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService, userService)
@ -93,11 +95,15 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
// Configure app routes
server.Mux.Route("/api", func(r chi.Router) {
// Set CORS headers for API routes in development mode
r.Use(middleware.SetHeader("Access-Control-Allow-Origin", "http://"+net.JoinHostPort(cfg.Host, cfg.UIPort)))
r.Use(middleware.SetHeader("Access-Control-Allow-Methods", "*"))
r.Use(middleware.SetHeader("Access-Control-Allow-Headers", "*"))
r.Use(middleware.SetHeader("Access-Control-Allow-Credentials", "true"))
r.Use(servermiddleware.Auth(userService))
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{
"http://" + net.JoinHostPort(cfg.Host, cfg.UIPort),
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowCredentials: true,
}))
r.Use(servermiddleware.Auth(userService, tokenService))
r.Mount("/", apiRouter)
})
server.Mux.Mount("/healthz", healthcheckRouter)

View File

@ -14,6 +14,7 @@ import (
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
staticssterver "git.maronato.dev/maronato/goshort/internal/server/static"
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"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"github.com/go-chi/chi/v5"
@ -50,6 +51,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
storage := shared.InitStorage(cfg)
shortService := shortservice.NewShortService(storage)
userService := userservice.NewUserService(cfg, storage)
tokenService := tokenservice.NewTokenService(storage)
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService, userService)
@ -68,7 +70,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
// Configure app routes
server.Mux.Route("/api", func(r chi.Router) {
r.Use(servermiddleware.Auth(userService))
r.Use(servermiddleware.Auth(userService, tokenService))
r.Mount("/", apiRouter)
})
server.Mux.Mount("/healthz", healthcheckRouter)

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -2,7 +2,7 @@ import { FunctionComponent, PropsWithChildren } from "react"
const Container: FunctionComponent<PropsWithChildren> = ({ children }) => {
return (
<div className="mx-auto max-w-7xl px-4 pb-20 sm:px-6 lg:px-8 w-full">
<div className="mx-auto max-w-4xl px-4 pb-20 sm:px-6 lg:px-8 w-full">
{children}
</div>
)

View File

@ -0,0 +1,88 @@
import {
FunctionComponent,
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import {
ChevronRightIcon,
ClipboardIcon,
TrashIcon,
} from "@heroicons/react/24/outline"
import { Link } from "react-router-dom"
import { useDoubleclickDelete } from "../hooks/useOnUpdateItem"
const ItemBase: FunctionComponent<
PropsWithChildren<{
copyString?: string
doDelete?: () => void
detailsPaage?: string
}>
> = ({ children, doDelete, copyString, detailsPaage }) => {
const [copied, copy] = useClipboardTimeout(copyString || "")
const [deleting, triggerDelete] = useDoubleclickDelete(
useMemo(() => doDelete ?? (() => {}), [doDelete])
)
return (
<li className="py-5 px-6 group duration-200 transition-colors bg-white flex flex-col">
<div>{children}</div>
<div className="flex flex-row justify-between items-center mt-2">
<div className="flex flex-row gap-2">
{copyString && (
<span
onClick={copy}
className="px-1 py-1 border border-slate-200 rounded-md block text-sm text-green-500 max-w-fit hover:text-white hover:bg-green-500 duration-200 transition-colors cursor-pointer select-none">
<ClipboardIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
{copied ? "Copied!" : "Copy"}
</span>
)}
{doDelete && (
<span
onClick={triggerDelete}
className="px-2 py-1 border border-slate-200 rounded-md block text-sm text-red-500 max-w-fit hover:text-white hover:bg-red-500 duration-200 transition-colors cursor-pointer select-none">
<TrashIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
{deleting ? "Are you sure?" : "Delete"}
</span>
)}
</div>
<div>
{detailsPaage && (
<Link to={`/sht/${name}`}>
<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>
<ChevronRightIcon className="my-auto w-5 h-5 " />
</div>
</Link>
)}
</div>
</div>
</li>
)
}
export default ItemBase
const useClipboardTimeout = (text: string): [boolean, () => void] => {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const copy = useCallback(
() => navigator.clipboard.writeText(text).then(() => setCopied(true)),
[text]
)
return [copied, copy]
}

View File

@ -0,0 +1,30 @@
import { FunctionComponent } from "react"
// Function that has a generic parameter
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
}) => {
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}
/>
))}
</ul>
)
}
export default ItemList

View File

@ -3,6 +3,8 @@ import { FunctionComponent } from "react"
import classNames from "classnames"
import { NavLink, useSearchParams } from "react-router-dom"
import { useIsAuthenticated } from "../hooks/useAuth"
const NavButton: FunctionComponent<{
text: string
onClick?: () => void
@ -29,9 +31,11 @@ const NavButton: FunctionComponent<{
// to the page they were on before they logged in.
const [searchParams] = useSearchParams()
const from = searchParams.get("from")
const isAuthenticated = useIsAuthenticated()
if (link) {
const linkWithFrom = from ? `${link}?from=${from}` : link
const linkWithFrom =
from && !isAuthenticated ? `${link}?from=${from}` : link
return (
<NavLink to={linkWithFrom}>
{({ isActive }) => <Content active={isActive} />}

View File

@ -23,10 +23,7 @@ export default function Navbar() {
{ name: "Tokens", href: "tkn" },
{ name: "Sessions", href: "ses" },
]
const unauthed = [
{ name: "Login", href: "lgn" },
{ name: "Signup", href: "sgn" },
]
const unauthed = [{ name: "Signup", href: "sgn" }]
return isAuthenticated ? authed : unauthed
}, [isAuthenticated])
@ -38,7 +35,7 @@ export default function Navbar() {
className={classNames("mx-auto max-w-7xl px-2 sm:px-6 lg:px-8", {
"bg-white": open,
})}>
<div className="relative flex h-16 items-center justify-between">
<div className="relative flex sm:grid sm:grid-cols-3 h-16 items-center justify-between">
<div className="absolute inset-y-0 left-0 flex items-center sm:hidden">
{/* Mobile menu button*/}
<Disclosure.Button className="relative inline-flex items-center justify-center rounded-md p-2 text-slate-500 hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500">
@ -51,11 +48,11 @@ export default function Navbar() {
)}
</Disclosure.Button>
</div>
<div className="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
<div className="flex flex-1 sm:grid sm:grid-cols-2 items-center justify-center sm:items-stretch sm:justify-start sm:col-span-2">
<div className="flex flex-shrink-0 items-center">
<NavButton text="GoShort" link="/" />
</div>
<div className="hidden sm:ml-6 sm:flex w-full flex-row justify-center">
<div className="hidden sm:flex w-full flex-row justify-center">
<div className="flex space-x-4">
{navigation.map((item) => (
<NavButton
@ -67,18 +64,18 @@ export default function Navbar() {
</div>
</div>
</div>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0 sm:col-span-1 justify-end">
{/* Profile dropdown */}
{isAuthenticated && (
<Menu as="div" className="relative ml-3">
<div>
<Menu.Button className="relative px-3 py-2 justify-items-center flex text-lg rounded-lg focus:outline-none focus:ring-none">
<Menu.Button className="relative py-2 justify-items-center flex rounded-lg focus:outline-none focus:ring-none">
<span className="absolute -inset-1.5" />
<span className="sr-only">Open user menu</span>
<span className="my-auto">{user?.username}</span>
<span className="my-auto">
<ChevronDownIcon
className="h-4 w-4 ml-2"
className="h-4 w-4 ml-1"
aria-hidden="true"
/>
</span>
@ -119,7 +116,7 @@ export default function Navbar() {
"bg-white shadow": open,
})}>
{({ close }) => (
<div className="space-y-1">
<div className="w-full space-y-1">
{navigation.map((item) => (
<NavLink
onClick={() => close()}

View File

@ -0,0 +1,21 @@
import { FunctionComponent } from "react"
import { Session } from "../types"
const SessionItem: FunctionComponent<Session & { doDelete: () => void }> = ({
doDelete,
...session
}) => {
return (
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
{JSON.stringify(session)}
<button
onClick={doDelete}
className="text-red-500 group-hover:text-red-600">
Delete
</button>
</li>
)
}
export default SessionItem

View File

@ -1,16 +1,14 @@
import { FunctionComponent, useCallback, useEffect, useState } from "react"
import { FunctionComponent } from "react"
import {
ArrowRightIcon,
ArrowTopRightOnSquareIcon,
ChevronRightIcon,
ClipboardIcon,
TrashIcon,
} from "@heroicons/react/24/outline"
import { Link } from "react-router-dom"
import { Short } from "../types"
import ItemBase from "./ItemBase"
const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
name,
url,
@ -19,27 +17,25 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
const origin = location.origin
const host = "marona.to"
const maxSize = 190
url = `${url}/fdfbdsjhbfsjhbfsjdhbfdsjhfbsjdhbfjshdbfjshdbfjshbfhsjbfdjhfbshjfbsdjhfbsjhfbsjdhfbsjhdfbsjdfhbdnfjkdsnfsjkfnsdkjfndskjfnskjfnskdjfnskjdfdfbdsjhbfsjhbfsjdhbfdsjhfbsjdhbfjshdbfjshdbfjshbfhsjbfdjhfbshjfbsdjhfbsjhfbsjdhfbsjhdfbsjdfhbdnfjkdsnfsjkfnsdkjfndskjfnskjfnskdjfnskjd`
const maxSize = 120
const displayURL =
url.length >= maxSize - 3 ? `${url.slice(0, maxSize)}...` : url
const shortNameURL = `${origin}/${name}`
const [copied, copy] = useClipboardTimeout(shortNameURL)
const [deleting, triggerDelete] = useDoubleclickDelete(doDelete)
return (
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
<div className="min-w-0 grid md:grid-cols-10 md:grid-rows-1 w-full grid-flow-row md:grid-flow-col">
<div className="col-span-5 md:col-span-3 lg:col-span-2 my-auto flex flex-col order-1">
<ItemBase
copyString={shortNameURL}
doDelete={doDelete}
detailsPaage={`/sht/${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
href={shortNameURL}
target="_blank"
rel="noreferrer"
className="flex flex-row font-semibold leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
<span className="truncate ">
<span className="break-all">
<span className="text-sm font-light text-blue-500">{host}/</span>
<span className="text-base">{name}</span>
</span>
@ -47,25 +43,11 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
</span>
</a>
<div className="flex flex-row gap-3">
<span
onClick={copy}
className="mt-2 px-2 py-1 border border-slate-200 rounded-md block text-xs text-gray-500 max-w-fit hover:text-white hover:bg-green-500 duration-200 transition-colors cursor-pointer select-none">
<ClipboardIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
{copied ? "Copied!" : "Copy"}
</span>
<span
onClick={triggerDelete}
className="mt-2 px-2 py-1 border border-slate-200 rounded-md block text-xs text-red-500 max-w-fit hover:text-white hover:bg-red-500 duration-200 transition-colors cursor-pointer select-none">
<TrashIcon className="inline-block w-4 h-4 mb-0.5 mr-1 leading-1" />
{deleting ? "Are you sure?" : "Delete"}
</span>
</div>
</div>
<div className="col-span-10 md:col-span-1 my-3 md:my-auto mr-auto ml-20 md:ml-2 order-3 md:order-2">
<div className="col-span-10 md:col-span-1 mb-1 md:my-auto mr-auto ml-10 md:ml-2 order-3 md:order-2">
<ArrowRightIcon className="w-6 h-6 text-green-500 rotate-90 md:rotate-0" />
</div>
<div className="col-span-10 md:col-span-4 lg:col-span-5 my-auto order-4 md:order-3">
<div className="col-span-10 md:col-span-6 my-auto order-4 md:order-3">
<a
href={url}
title={url}
@ -86,58 +68,10 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
<p className="mt-1 text-xs leading-5 text-slate-400">
Last viewed <time dateTime="2023-01-23T13:23Z">3h ago</time>
</p>
<Link to={`/sht/${name}`}>
<div className="py-1 my-1 text-xs 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>
<ChevronRightIcon className="my-auto w-4 h-4 " />
</div>
</Link>
</div>
</div>
</li>
</ItemBase>
)
}
export default ShortItem
const useClipboardTimeout = (text: string): [boolean, () => void] => {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (copied) {
const timeout = setTimeout(() => {
setCopied(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [copied])
const copy = useCallback(
() => navigator.clipboard.writeText(text).then(() => setCopied(true)),
[text]
)
return [copied, copy]
}
const useDoubleclickDelete = (doDelete: () => void): [boolean, () => void] => {
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (deleting) {
const timeout = setTimeout(() => {
setDeleting(false)
}, 5000)
return () => clearTimeout(timeout)
}
}, [deleting])
const trigger = useCallback(() => {
if (deleting) {
doDelete()
} else {
setDeleting(true)
}
}, [doDelete, deleting])
return [deleting, trigger]
}

View File

@ -0,0 +1,13 @@
import { FunctionComponent } from "react"
import { Token } from "../types"
const TokenItem: FunctionComponent<Token & { doDelete: () => void }> = () => {
return (
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
name
</li>
)
}
export default TokenItem

View File

@ -32,7 +32,7 @@ export const AuthProvider: AuthProviderType = {
})
if (response.ok) {
this.user = response.data
this.user = response.data as User
this.isAuthenticated = true
return true
}
@ -65,7 +65,7 @@ export const indexLoader: LoaderFunction =
async (): Promise<IndexLoaderData> => {
const response = await fetchAPI<User>("/me")
if (response.ok) {
AuthProvider.user = response.data
AuthProvider.user = response.data as User
AuthProvider.isAuthenticated = true
}
return {
@ -131,9 +131,6 @@ export const signupLoader = loginLoader
export const logoutLoader: LoaderFunction = async () => {
await AuthProvider.logout()
setTimeout(() => {
location.reload()
}, 100)
return redirect("/")
}

View File

@ -0,0 +1,73 @@
import { SetStateAction, useCallback, useEffect, useState } from "react"
import fetchAPI from "../util/fetchAPI"
export const useOnDelete = <T extends Record<string, unknown>>(
apiPath: string,
idKey: keyof T,
setData: (value: SetStateAction<T[]>) => void
) => {
return useCallback(
(key: string) => async () => {
// optimistic update
setData((data) => data.filter((item) => item[idKey] !== key))
// do the actual delete
await fetchAPI(`${apiPath}/${key}`, {
method: "DELETE",
})
},
[setData, idKey, apiPath]
)
}
export const useDoubleclickDelete = (
doDelete: () => void
): [boolean, () => void] => {
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (deleting) {
const timeout = setTimeout(() => {
setDeleting(false)
}, 5000)
return () => clearTimeout(timeout)
}
}, [deleting])
const trigger = useCallback(() => {
if (deleting) {
doDelete()
} else {
setDeleting(true)
}
}, [doDelete, deleting])
return [deleting, trigger]
}
export const useOnCreate = <T extends Record<string, unknown>>(
apiPath: string,
setData: (value: SetStateAction<T[]>) => void
) => {
const [creating, setCreating] = useState(false)
const create = useCallback(
async (payload?: T) => {
// Avoid multiple creates
if (creating) return
setCreating(true)
const response = await fetchAPI<T>(`${apiPath}`, {
method: "POST",
body: JSON.stringify(payload),
})
if (response.ok && response.data) {
// Add response to data
const newData = response.data as T
setData((data) => [...data, newData])
}
setCreating(false)
},
[setData, apiPath, creating]
)
return [creating, create] as const
}

View File

@ -0,0 +1,22 @@
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,9 +1,165 @@
import { FunctionComponent } from "react"
import { FunctionComponent, useEffect, useMemo, useRef, useState } from "react"
import {
Form,
LoaderFunction,
useActionData,
useNavigation,
} from "react-router-dom"
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"
type ActionResponse =
| {
short: Short
}
| { error: string }
export const Component: FunctionComponent = () => {
const form = useRef<HTMLFormElement>(null)
const navigation = useNavigation()
const isShortening = navigation.formData?.get("url") != null
const [name, setName] = useState("")
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^a-z0-9-_]/gi, "")
setName(value)
}
const actionData = useActionData() as ActionResponse | undefined
const [shorts, setShorts] = useState<Short[]>(
useMemo(
() => [
{
name: "test",
url: "https://example.com",
},
],
[]
)
)
useEffect(() => {
if (actionData && "short" in actionData) {
setShorts((shorts) => [actionData.short, ...shorts])
// If success, also reset the form and remove focus
form.current?.reset()
setName("")
for (const input of form.current?.elements || []) {
;(input as HTMLElement).blur()
}
}
}, [actionData])
const deleteItem = useOnDelete("/shorts", "name", setShorts)
return (
<div className="pt-20 pb-10">
<p>hello</p>
</div>
<>
<Header title="" />
<Form
method="post"
replace
ref={form}
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">
Paste your{" "}
<span className="ml-1 before:block before:absolute before:-inset-1 before:-skew-y-3 before:bg-gradient-to-br before:from-blue-300 before:to-blue-500 relative inline-block">
<span className="relative text-white">long URL</span>
</span>
</span>
<input
type="url"
name="url"
autoComplete="off"
autoCorrect="off"
required
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"
/>
</label>
<label className="flex flex-col gap-2 max-w-fit">
<span className="text-base xl:text-lg after:content-['optional'] after:ml-0.5 after:text-slate-500 block after:text-xs after:absolute after:justify-start font-medium">
Choose a{" "}
<span className="text-green-500 font-bold">custom link</span>
</span>
<div className="bg-white text-sm xl:text-base sm:max-w-2xl ring-slate-300 ring-inset ring-1 shadow-sm rounded-md flex focus-within:ring-blue-500 outline-0 focus-within:ring-2 transition-all duration-200">
<span className=" text-slate-400 leading-5 pl-3 items-center select-none flex">
{`${location.host}/`}
</span>
<input
type="text"
name="name"
minLength={4}
maxLength={20}
value={name}
onChange={handleNameChange}
autoComplete="off"
autoCorrect="off"
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>
) : null}
<button
type="submit"
className="px-4 py-3 rounded-md bg-blue-100 text-blue-500 hover:bg-blue-200 text-2xl font-semibold transition-colors duration-200 disabled:bg-slate-200 disabled:text-slate-400">
{isShortening ? "Shortening..." : "Shorten it"}
</button>
</Form>
<div className="mt-10">
<ItemList
items={shorts}
Item={ShortItem}
idKey="name"
deleteItem={deleteItem}
/>
</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 }
}

View File

@ -1,41 +1,76 @@
import {
Form,
Link,
useActionData,
useLocation,
useNavigation,
useSearchParams,
} from "react-router-dom"
import Header from "../components/Header"
export function Component() {
const location = useLocation()
const params = new URLSearchParams(location.search)
const [params] = useSearchParams()
const from = params.get("from") || "/"
const navigation = useNavigation()
const isLoggingIn = navigation.formData?.get("username") != null
const isSigningUp = navigation.formData?.get("username") != null
const actionData = useActionData() as { error: string } | undefined
// Since a user may go from the login page to the signup page, we want to
// preserve the `from` query parameter so that we can redirect the user back
// to the page they were on before they logged in.
const signuplink = from ? `/sgn?from=${from}` : "/sgn"
return (
<>
<Header title="Login" />
<p>You must log in to view the page at {from}</p>
<Form method="post" replace>
<input type="hidden" name="redirectTo" value={from} />
<label>
Username: <input name="username" />
</label>{" "}
<label>
Password: <input type="password" name="password" />
</label>{" "}
<button type="submit" disabled={isLoggingIn}>
{isLoggingIn ? "Logging in..." : "Login"}
</button>
<Header title="" />
<Form
method="post"
replace
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
<span className="text-3xl font-bold text-center mb-4">Log in</span>
{actionData && actionData.error ? (
<p style={{ color: "red" }}>{actionData.error}</p>
<p className="text-red-500 text-center font-medium">
{actionData.error}
</p>
) : null}
<input type="hidden" name="redirectTo" value={from} />
<label className="flex flex-col">
<input
name="username"
minLength={4}
maxLength={128}
required
placeholder="username"
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<label className="flex flex-col">
<input
name="password"
type="password"
minLength={8}
maxLength={128}
required
placeholder="password"
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<button
className="mt-6 px-8 py-3 bg-blue-100 text-blue-600 font-bold rounded max-w-fit mx-auto hover:bg-blue-200 disabled:bg-slate-200 disabled:hover:bg-slate-200 disabled:text-slate-600 transition-colors duration-200"
type="submit"
disabled={isSigningUp}>
{isSigningUp ? "Logging in..." : "Log in"}
</button>
<span className="text-slate-500 font-light text-center text-sm">
{`Don't have an account? `}
<Link
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
to={signuplink}>
Sign up
</Link>
</span>
</Form>
</>
)

View File

@ -1,50 +1,73 @@
import { LoaderFunction, json, useLoaderData } from "react-router-dom"
import { FunctionComponent, useCallback, useMemo } from "react"
import { LoaderFunction, redirect, useNavigate } from "react-router-dom"
import Header from "../components/Header"
import SessionItem from "../components/SessionItem"
import { protectedLoader } from "../hooks/useAuth"
type Session = {
id: number
title: string
description: string
}
export const loader: LoaderFunction = async (args) => {
const redirect = await protectedLoader(args)
if (redirect) return redirect
const data: Session[] = [
{
id: 1,
title: "Session 1",
description: "Session 1 description",
},
{
id: 2,
title: "Session 2",
description: "Session 2 description",
},
{
id: 3,
title: "Session 3",
description: "Session 3 description",
},
]
return json(data)
}
import { useOnDelete } from "../hooks/useOnUpdateItem"
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
import { Session } from "../types"
import fetchAPI from "../util/fetchAPI"
export function Component() {
const data = useLoaderData() as Session[]
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 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 (
<>
<Header title="Sessions" />
<ul>
{data.map((s) => (
<li key={s.id}>{s.title}</li>
))}
</ul>
{data.length > 0 ? <Sessions /> : <NoSessions />}
</>
)
}
export const loader: LoaderFunction = async (args) => {
const resp = await protectedLoader(args)
if (resp) return resp
const data = await fetchAPI<Session[]>("/sessions")
if (data.ok) {
return data.data
}
return redirect("/lgo")
}
Component.displayName = "SessionsPage"

View File

@ -22,9 +22,9 @@ export const loader: LoaderFunction = async (args) => {
const resp = await protectedLoader(args)
if (resp) return resp
const data = await fetchAPI<Short[]>("/shorts")
const data = await fetchAPI<Short>(`/shorts/${args.params.name}`)
if (data.ok) {
return data.data?.find((short) => short.name === args.params.name)
return data.data
}
return redirect("/lgo")
}

View File

@ -1,79 +1,43 @@
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { FunctionComponent, useMemo } from "react"
import {
LoaderFunction,
redirect,
useLoaderData,
useRevalidator,
} from "react-router-dom"
import { LoaderFunction, redirect } from "react-router-dom"
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 { Short } from "../types"
import fetchAPI from "../util/fetchAPI"
export function Component() {
const sourceDataDefault = useMemo(() => [], [])
const sourceData = (useLoaderData() ?? sourceDataDefault) as Short[]
const [data, setData] = useState(
sourceData
.map((short) => short)
.sort((a, b) => a.name.localeCompare(b.name))
const [shorts, setShorts] = useSortedLoadedItems<Short>(
useMemo(() => (a, b) => a.name.localeCompare(b.name), [])
)
useEffect(() => {
setData(
sourceData
.map((short) => short)
.sort((a, b) => a.name.localeCompare(b.name))
)
}, [sourceData])
const { revalidate } = useRevalidator()
const deleteShort = useCallback(
(name: string) => async () => {
// optimistic update
setData((data) => data.filter((short) => short.name !== name))
// do the actual delete
await fetchAPI(`/shorts/${name}`, {
method: "DELETE",
})
revalidate()
},
[revalidate]
)
const deleteItem = useOnDelete("/shorts", "name", setShorts)
const Shorts: FunctionComponent = () => {
return (
<ul
role="list"
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
{data.map((short) => (
<ShortItem
{...short}
doDelete={deleteShort(short.name)}
key={short.name}
/>
))}
</ul>
<ItemList
items={shorts}
Item={ShortItem}
idKey="name"
deleteItem={deleteItem}
/>
)
}
const NoShorts = () => {
return <div className="text-center pt-5 text-xl font-light">No Shorts</div>
return (
<div className="text-center pt-5 text-xl font-light">No shorts yet</div>
)
}
return (
<>
<Header title="Shorts" />
{data.length > 0 ? <Shorts /> : <NoShorts />}
{shorts.length > 0 ? <Shorts /> : <NoShorts />}
</>
)
}

View File

@ -1,44 +1,79 @@
import {
Form,
Link,
useActionData,
useLocation,
useNavigation,
useSearchParams,
} from "react-router-dom"
import Header from "../components/Header"
export function Component() {
const location = useLocation()
const params = new URLSearchParams(location.search)
const [params] = useSearchParams()
const from = params.get("from") || "/"
const navigation = useNavigation()
const isLoggingIn = navigation.formData?.get("username") != null
const isSigningUp = navigation.formData?.get("username") != null
const actionData = useActionData() as { error: string } | undefined
// Since a user may go from the login page to the signup page, we want to
// preserve the `from` query parameter so that we can redirect the user back
// to the page they were on before they logged in.
const loginLink = from ? `/lgn?from=${from}` : "/lgn"
return (
<>
<Header title="Signup" />
<p>You must log in to view the page at {from}</p>
<Form method="post" replace>
<input type="hidden" name="redirectTo" value={from} />
<label>
Username: <input name="username" />
</label>{" "}
<label>
Password: <input type="password" name="password" />
</label>{" "}
<button type="submit" disabled={isLoggingIn}>
{isLoggingIn ? "Logging in..." : "Login"}
</button>
<Header title="" />
<Form
method="post"
replace
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
<span className="text-3xl font-bold text-center mb-4">Sign up</span>
{actionData && actionData.error ? (
<p style={{ color: "red" }}>{actionData.error}</p>
<p className="text-red-500 text-center font-medium">
{actionData.error}
</p>
) : null}
<input type="hidden" name="redirectTo" value={from} />
<label className="flex flex-col">
<input
name="username"
minLength={4}
maxLength={128}
required
placeholder="username"
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<label className="flex flex-col">
<input
name="password"
type="password"
minLength={8}
maxLength={128}
required
placeholder="password"
className="p-2 font-light border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<button
className="mt-6 px-8 py-3 bg-blue-100 text-blue-600 font-bold rounded max-w-fit mx-auto hover:bg-blue-200 disabled:bg-slate-200 disabled:hover:bg-slate-200 disabled:text-slate-600 transition-colors duration-200"
type="submit"
disabled={isSigningUp}>
{isSigningUp ? "Signing up..." : "Sign up"}
</button>
<span className="text-slate-500 font-light text-center text-sm">
{`Have an account? `}
<Link
className="text-blue-500 font-medium transition-colors duration-200 hover:text-blue-600"
to={loginLink}>
Log in
</Link>
</span>
</Form>
</>
)
}
Component.displayName = "LoginPage"
Component.displayName = "SignupPage"

View File

@ -1,37 +1,70 @@
import { LoaderFunction, json, useLoaderData } from "react-router-dom"
import { FunctionComponent, useMemo } from "react"
import { PlusIcon } from "@heroicons/react/24/outline"
import classNames from "classnames"
import { LoaderFunction, redirect } from "react-router-dom"
import Header from "../components/Header"
import TokenItem from "../components/TokenItem"
import { protectedLoader } from "../hooks/useAuth"
type Token = {
id: number
name: string
}
export const loader: LoaderFunction = async (args) => {
const redirect = await protectedLoader(args)
if (redirect) return redirect
const data: Token[] = [
{ id: 1, name: "Token 1" },
{ id: 2, name: "Token 2" },
{ id: 3, name: "Token 3" },
]
return json(data)
}
import { useOnCreate, useOnDelete } from "../hooks/useOnUpdateItem"
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
import { Token } from "../types"
export function Component() {
const data = useLoaderData() as Token[]
const [data, setData] = useSortedLoadedItems<Token>(
useMemo(() => (a, b) => b.createdAt.localeCompare(a.createdAt), [])
)
const deleteItem = useOnDelete("/tokens", "id", setData)
const [creating, createItem] = useOnCreate("/tokens", setData)
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>
)
}
const NoTokens = () => {
return (
<div className="text-center pt-5 text-xl font-light">No tokens yet</div>
)
}
return (
<>
<Header title="Tokens" />
<ul>
{data.map((s) => (
<li key={s.id}>{s.name}</li>
))}
</ul>
<button
disabled={creating}
onClick={() => createItem()}
className="absolute self-end mt-14 px-4 py-3 text-blue-500 flex flex-row font-semibold text-lg hover:text-blue-600 transition-colors duration-200 disabled:text-slate-500">
<PlusIcon
className={classNames("h-4 w-4 mr-1 my-auto", {
"animate-spin": creating,
})}
/>
<span>New token</span>
</button>
{data.length > 0 ? <Tokens /> : <NoTokens />}
</>
)
}
export const loader: LoaderFunction = async (args) => {
const resp = await protectedLoader(args)
if (resp) return resp
// const data = await fetchAPI<Session[]>("/sessions")
const data = { ok: true, data: [] }
if (data.ok) {
return data.data
}
return redirect("/lgo")
}
Component.displayName = "TokensPage"

View File

@ -6,3 +6,19 @@ export type Short = {
name: string
url: string
}
export type Session = {
id: string
username: string
ip: string
userAgent: string
lastActivity: string
createdAt: string
current: boolean
}
export type Token = {
id: string
value: string
createdAt: string
}

View File

@ -1,31 +1,54 @@
type ErrorResponse = {
status: string
error: string
}
type Result<T> =
| {
data: T | string
ok: true
}
| {
data: string
ok: false
}
// Fetch function that automatically points to the API URL
export default async function <T>(
path: string,
args: Parameters<typeof fetch>[1] = {}
): Promise<{ data: T | null; ok: boolean }> {
): Promise<Result<T>> {
if (import.meta.env.DEV) {
args.credentials = "include"
}
const response = await fetch(
`${import.meta.env.VITE_API_URL || ""}${path}`,
args
)
let responseData: T | null = null
let response: Response
try {
responseData = await response.json()
response = await fetch(`${import.meta.env.VITE_API_URL || ""}${path}`, args)
} catch (e) {
responseData = null
console.error(e)
return {
data: (e as Error).message,
ok: false,
}
}
// if the response was not ok
if (!response.ok) {
console.error(response.statusText)
const responseClone = response.clone()
const dataOrString = await response.json().catch(() => responseClone.text())
return { data: responseData, ok: false }
if (response.ok) {
return {
data: dataOrString,
ok: true,
}
}
// on a successfull response, return the data
return { data: responseData, ok: true }
return {
data:
(dataOrString as ErrorResponse)?.error ||
(dataOrString as ErrorResponse)?.status ||
response.statusText,
ok: false,
}
}

1
go.mod
View File

@ -13,5 +13,6 @@ require (
require (
github.com/ajg/form v1.5.1 // indirect
github.com/go-chi/cors v1.2.1 // indirect
golang.org/x/sys v0.11.0 // indirect
)

2
go.sum
View File

@ -4,6 +4,8 @@ github.com/alexedwards/scs/v2 v2.5.1 h1:EhAz3Kb3OSQzD8T+Ub23fKsiuvE0GzbF5Lgn0uTw
github.com/alexedwards/scs/v2 v2.5.1/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=

View File

@ -26,6 +26,14 @@ var (
ErrInvalidUsernameOrPassword = errors.New("invalid username or password")
// ErrRegistrationDisabled
ErrRegistrationDisabled = errors.New("registration is disabled")
// ErrSessionDoesNotExist
ErrSessionDoesNotExist = errors.New("session does not exist")
// ErrTokenDoesNotExist
ErrTokenDoesNotExist = errors.New("token does not exist")
// ErrTokenExists
ErrTokenExists = errors.New("token already exists")
// ErrTokenMissing
ErrTokenMissing = errors.New("token missing")
)
func Error(err error, msg string) error {

View File

@ -29,12 +29,10 @@ func NewAPIHandler(shorts *shortservice.ShortService, users *userservice.UserSer
}
func (h *APIHandler) Me(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := servermiddleware.UserFromCtx(ctx)
user, ok := h.findUserOrRespond(w, r)
if !ok {
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
return
}
// Respond with the user
@ -46,15 +44,17 @@ func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := servermiddleware.UserFromCtx(ctx)
user, ok := h.findUserOrRespond(w, r)
if !ok {
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
return
}
// Delete the user
err := h.users.DeleteUser(ctx, user)
if err != nil {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
return
}
// Logout and return
@ -73,7 +73,7 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
var login *loginForm
if err := render.DecodeJSON(r.Body, &login); err != nil {
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
return
}
@ -83,20 +83,20 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
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.RenderRender(w, r, server.ErrUnauthorized(errs.ErrInvalidUsernameOrPassword))
server.RenderUnauthorized(w, r, errs.ErrInvalidUsernameOrPassword)
} else if errors.Is(err, errs.ErrInvalidUser) {
// If the request was invalid, return bad request
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
} else {
// Else, server error
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
return
}
// Login user
servermiddleware.LoginUser(r.Context(), user)
servermiddleware.LoginUser(ctx, user, r)
// Render the response
render.Status(r, http.StatusOK)
@ -126,7 +126,7 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
if err := render.DecodeJSON(r.Body, &form); err != nil {
err = fmt.Errorf("failed to parse form: %w", err)
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
return
}
@ -138,9 +138,9 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
// Hash the password into the user
if err := user.SetPassword(pass); err != nil {
if errors.Is(err, errs.ErrInvalidUsernameOrPassword) {
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
} else {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
return
@ -150,11 +150,11 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
err := h.users.CreateUser(ctx, user)
if err != nil {
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
server.RenderRender(w, r, server.ErrForbidden(err))
server.RenderForbidden(w, r, err)
} else {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
return
@ -172,27 +172,27 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
var short *models.Short
if err := render.DecodeJSON(r.Body, &short); err != nil {
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
return
}
// Get user from context
user, ok := servermiddleware.UserFromCtx(ctx)
user, ok := h.findUserOrRespond(w, r)
if !ok {
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
return
}
// Make sure user is set
// Set the user
short.User = user
// Shorten URL
short, err := h.shorts.Shorten(ctx, short)
if err != nil {
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
server.RenderRender(w, r, server.ErrBadRequest(err))
server.RenderBadRequest(w, r, err)
} else {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
return
@ -207,15 +207,15 @@ func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := servermiddleware.UserFromCtx(ctx)
user, ok := h.findUserOrRespond(w, r)
if !ok {
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
return
}
// Get shorts
shorts, err := h.shorts.ListShorts(ctx, user)
if err != nil {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
return
}
@ -225,9 +225,108 @@ func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, shorts)
}
func (h *APIHandler) FindShort(w http.ResponseWriter, r *http.Request) {
// Find own short or respond
short, ok := h.findShortOrRespond(w, r)
if !ok {
return
}
// Render the short
render.Status(r, http.StatusOK)
render.JSON(w, r, short)
}
func (h *APIHandler) DeleteShort(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Find own short or respond
short, ok := h.findShortOrRespond(w, r)
if !ok {
return
}
// Delete short
err := h.shorts.DeleteShort(ctx, short)
if err != nil {
server.RenderServerError(w, r, err)
return
}
// Deleted, return no content
render.NoContent(w, r)
}
func (h *APIHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
sessions, err := servermiddleware.ListUserSessions(ctx, user)
if err != nil {
server.RenderServerError(w, r, err)
}
// Render the response
render.Status(r, http.StatusOK)
render.JSON(w, r, sessions)
}
func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
// Get session token from request
sessionToken := chi.URLParam(r, "id")
// Delete session
err := servermiddleware.DeleteUserSession(ctx, user, sessionToken)
if err != nil {
if errors.Is(err, errs.ErrSessionDoesNotExist) {
server.RenderNotFound(w, r, err)
} else {
server.RenderServerError(w, r, err)
}
return
}
// Render the response
render.Status(r, http.StatusNoContent)
}
// findUserOrRespond is a helper function that finds a user in the session,
// and returns it. If the user is not found, it returns nil and false.
func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (user *models.User, ok bool) {
ctx := r.Context()
// Get user from context
user, ok = servermiddleware.UserFromCtx(ctx)
if !ok {
server.RenderServerError(w, r, errs.ErrInvalidUser)
return nil, false
}
return user, true
}
// findShortWithAuth 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) {
ctx := r.Context()
// Get short name from request
name := chi.URLParam(r, "short")
@ -236,36 +335,27 @@ func (h *APIHandler) DeleteShort(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.RenderRender(w, r, server.ErrNotFound(err))
server.RenderNotFound(w, r, err)
} else {
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
return
return nil, false
}
// Get user from context
user, ok := servermiddleware.UserFromCtx(ctx)
user, ok := h.findUserOrRespond(w, r)
if !ok {
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
return nil, false
}
// If the session user does not match the short's user,
// return forbidden.
if user.Username != short.User.Username {
server.RenderRender(w, r, server.ErrForbidden(fmt.Errorf("you do not have permission to delete this short")))
server.RenderForbidden(w, r, fmt.Errorf("this short is not yours"))
return
return nil, false
}
// Delete short
err = h.shorts.DeleteShort(ctx, short)
if err != nil {
server.RenderRender(w, r, server.ErrServerError(err))
return
}
// Deleted, return no content
render.NoContent(w, r)
return short, true
}

View File

@ -26,7 +26,12 @@ func NewAPIRouter(h *APIHandler) http.Handler {
// Shorts routes
r.Get("/shorts", h.ListShorts)
r.Post("/shorts", h.CreateShort)
r.Get("/shorts/{short}", h.FindShort)
r.Delete("/shorts/{short}", h.DeleteShort)
// Sessions routes
r.Get("/sessions", h.ListSessions)
r.Delete("/sessions/{id}", h.DeleteSession)
})
return mux

View File

@ -73,3 +73,27 @@ func RenderRender(w http.ResponseWriter, r *http.Request, resp render.Renderer)
}
}
}
func RenderBadRequest(w http.ResponseWriter, r *http.Request, err error) {
RenderRender(w, r, ErrBadRequest(err))
}
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 RenderUnauthorized(w http.ResponseWriter, r *http.Request, err error) {
RenderRender(w, r, ErrUnauthorized(err))
}
func RenderForbidden(w http.ResponseWriter, r *http.Request, err error) {
RenderRender(w, r, ErrForbidden(err))
}
func RenderRendering(w http.ResponseWriter, r *http.Request, err error) {
RenderRender(w, r, ErrRendering(err))
}

View File

@ -2,41 +2,67 @@ package servermiddleware
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"git.maronato.dev/maronato/goshort/internal/errs"
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/alexedwards/scs/v2"
)
const sessionUserKey = "user"
const (
sessionUserKey = "user"
sessionIPKey = "ip"
sessionUserAgentKey = "user_agent"
sessionLastActivityKey = "last_activity"
sessionCreatedAtKey = "created_at"
tokenHeader = "Authorization"
)
type userContextKey struct{}
func Auth(userService *userservice.UserService) func(http.Handler) http.Handler {
type AuthSessionData struct {
// Username is the username of the user to whom the session belongs.
Username string `json:"username"`
// IP is the last IP address used by with the session.
IP string `json:"ip"`
// UserAgent is the last User-Agent used with the session.
UserAgent string `json:"userAgent"`
// LastActivity is the last time the session was used.
LastActivity time.Time `json:"lastActivity"`
// CreatedAt is the time the session was created.
CreatedAt time.Time `json:"createdAt"`
}
type Session struct {
AuthSessionData
// ID is the session id.
ID string `json:"id"`
// Current is true if the session is the current session.
Current bool `json:"current"`
// Token is the session token.
}
func Auth(userService *userservice.UserService, tokenService *tokenservice.TokenService) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Get session from context
ctx := r.Context()
session := SessionFromCtx(ctx)
// If no session exists, call the next handler
if !session.Exists(ctx, sessionUserKey) {
next.ServeHTTP(w, r)
return
}
// Get username from session
username := session.GetString(ctx, sessionUserKey)
// Get user from storage
user, err := userService.FindUser(ctx, username)
// Authenticate user
user, err := authenticateViaToken(r, tokenService)
if err != nil {
// Since we couldn't find the user, remove the key from session and call the next handler
LogoutUser(ctx)
// Failed to authenticate via token. Try to authenticate via session.
user, err = authenticateUserViaSession(r, userService)
if err != nil {
// Failed to authenticate via session. Call the next handler.
next.ServeHTTP(w, r)
next.ServeHTTP(w, r)
return
return
}
}
// Add user to context
@ -68,14 +94,201 @@ func UserFromCtx(ctx context.Context) (*models.User, bool) {
return user, ok
}
func LoginUser(ctx context.Context, user *models.User) {
session := SessionFromCtx(ctx)
func SessionDataFromCtx(manager *scs.SessionManager, sessionCtx context.Context) *AuthSessionData {
// Get data from session
username := manager.GetString(sessionCtx, sessionUserKey)
ip := manager.GetString(sessionCtx, sessionIPKey)
userAgent := manager.GetString(sessionCtx, sessionUserAgentKey)
lastActivity, err := time.Parse(time.RFC3339, manager.GetString(sessionCtx, sessionLastActivityKey))
if err != nil {
panic(err)
}
createdAt, err := time.Parse(time.RFC3339, manager.GetString(sessionCtx, sessionCreatedAtKey))
if err != nil {
panic(err)
}
session.Put(ctx, sessionUserKey, user.Username)
// Create new session data
sessionData := &AuthSessionData{
Username: username,
IP: ip,
UserAgent: userAgent,
LastActivity: lastActivity,
CreatedAt: createdAt,
}
return sessionData
}
func UpdateSession(ctx context.Context, r *http.Request) {
manager := SessionManagerFromCtx(ctx)
// Get data from request
ip := r.RemoteAddr
userAgent := r.UserAgent()
lastActivity := time.Now().Format(time.RFC3339)
// Update session
manager.Put(ctx, sessionIPKey, ip)
manager.Put(ctx, sessionUserAgentKey, userAgent)
manager.Put(ctx, sessionLastActivityKey, lastActivity)
}
func LoginUser(ctx context.Context, user *models.User, r *http.Request) {
manager := SessionManagerFromCtx(ctx)
manager.Put(ctx, sessionUserKey, user.Username)
manager.Put(ctx, sessionCreatedAtKey, time.Now().Format(time.RFC3339))
// Update session
UpdateSession(ctx, r)
}
func LogoutUser(ctx context.Context) {
session := SessionFromCtx(ctx)
manager := SessionManagerFromCtx(ctx)
session.Remove(ctx, sessionUserKey)
err := manager.Destroy(ctx)
if err != nil {
panic(err)
}
}
func UsernameFromSession(manager *scs.SessionManager, sessionCtx context.Context) string {
return manager.GetString(sessionCtx, sessionUserKey)
}
var (
errStopIteration = errors.New("stop iteration")
)
func iterateUserSessions(ctx context.Context, user *models.User, callback func(sessionCtx context.Context) error) error {
manager := SessionManagerFromCtx(ctx)
err := manager.Iterate(ctx, func(sessionCtx context.Context) error {
// Get the username from the session and check against the current user
sessionUsername := UsernameFromSession(manager, sessionCtx)
// If they match, call the callback
if sessionUsername == user.Username {
return callback(sessionCtx)
}
return nil
})
if err != nil && err != errStopIteration {
return fmt.Errorf("error iterating through sessions: %w", err)
}
return nil
}
func ListUserSessions(ctx context.Context, user *models.User) (sessions []Session, err error) {
manager := SessionManagerFromCtx(ctx)
currentSessionToken := manager.Token(ctx)
err = iterateUserSessions(ctx, user, func(sessionCtx context.Context) error {
// Get session data and add it to the list
authData := SessionDataFromCtx(manager, sessionCtx)
sessionToken := manager.Token(sessionCtx)
sessionData := &Session{
AuthSessionData: *authData,
Current: sessionToken == currentSessionToken,
ID: sessionToken,
}
sessions = append(sessions, *sessionData)
return nil
})
if err != nil {
return []Session{}, fmt.Errorf("error listing user sessions: %w", err)
}
return sessions, nil
}
func DeleteUserSession(ctx context.Context, user *models.User, sessionToken string) error {
manager := SessionManagerFromCtx(ctx)
found := false
err := iterateUserSessions(ctx, user, func(sessionCtx context.Context) error {
// Get session token from session
token := manager.Token(sessionCtx)
// If the token matches, destroy the session
if token == sessionToken {
err := manager.Destroy(sessionCtx)
if err != nil {
return fmt.Errorf("error destroying session: %w", err)
}
found = true
// Since we found the session, we can stop iterating
return errStopIteration
}
return nil
})
if err != nil {
return fmt.Errorf("error iterating through user sessions: %w", err)
}
if !found {
return errs.ErrSessionDoesNotExist
}
return nil
}
func authenticateUserViaSession(r *http.Request, userService *userservice.UserService) (user *models.User, err error) {
ctx := r.Context()
manager := SessionManagerFromCtx(ctx)
// If no session exists, call the next handler
if !manager.Exists(ctx, sessionUserKey) {
return nil, errs.ErrSessionDoesNotExist
}
// Get username from session
username := UsernameFromSession(manager, ctx)
// Get user from storage
user, err = userService.FindUser(ctx, username)
if err != nil {
// Since we couldn't find the user, destroy the session
LogoutUser(ctx)
return nil, errs.ErrUserDoesNotExist
}
// Update session
UpdateSession(ctx, r)
return user, nil
}
func authenticateViaToken(r *http.Request, tokenService *tokenservice.TokenService) (user *models.User, err error) {
ctx := r.Context()
// Get token from request
value := r.Header.Get(tokenHeader)
if value == "" {
return nil, errs.ErrTokenMissing
}
// Get token from storage
token, err := tokenService.FindToken(ctx, value)
if err != nil {
return nil, fmt.Errorf("error authenticating via token: %w", err)
}
// Get user from token
user = token.User
return user, nil
}

View File

@ -11,7 +11,7 @@ import (
type sessionContextKey struct{}
// Session initializes and adds a session manager to the request context.
func Session(cfg *config.Config) func(http.Handler) http.Handler {
func SessionManager(cfg *config.Config) func(http.Handler) http.Handler {
sessionManager := scs.New()
sessionManager.Lifetime = config.DefaultSessionDuration
sessionManager.Cookie = scs.SessionCookie{
@ -38,7 +38,7 @@ func Session(cfg *config.Config) func(http.Handler) http.Handler {
}
}
func SessionFromCtx(ctx context.Context) *scs.SessionManager {
func SessionManagerFromCtx(ctx context.Context) *scs.SessionManager {
sessionManager, err := ctx.Value(sessionContextKey{}).(*scs.SessionManager)
if !err {
// This should never happen.

View File

@ -30,7 +30,7 @@ func NewServer(cfg *config.Config) *Server {
mux.Use(middleware.RealIP)
mux.Use(middleware.Logger)
mux.Use(middleware.Recoverer)
mux.Use(servermiddleware.Session(cfg))
mux.Use(servermiddleware.SessionManager(cfg))
mux.Use(middleware.Timeout(config.RequestTimeout))
// Create the server

View File

@ -0,0 +1,102 @@
package tokenservice
import (
"context"
"fmt"
"strings"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/storage/models"
shortutil "git.maronato.dev/maronato/goshort/internal/util/short"
)
const (
// DefaultTokenLength is the default length of a token.
TokenLength = 32
// TokenPrefix is the prefix of a token.
TokenPrefix = "goshort-token:"
// DefaultTokenIDLength is the default length of a token ID.
TokenIDLength = 16
// MinTokenNameLength is the minimum length of a token name.
MinTokenNameLength = 4
// MaxTokenNameLength is the maximum length of a token name.
MaxTokenNameLength = 32
)
// TokenService is the service that handles tokens.
type TokenService struct {
// db is the storage used by the service.
db storage.Storage
}
// NewTokenService creates a new TokenService.
func NewTokenService(db storage.Storage) *TokenService {
return &TokenService{
db: db,
}
}
// FindToken finds a token in the storage using its value.
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")
}
if len(value) != TokenLength+len(TokenPrefix) {
return &models.Token{}, fmt.Errorf("invalid token")
}
// Get the token from storage
token, err := s.db.FindToken(ctx, value)
if err != nil {
return token, fmt.Errorf("could not get token from storage: %w", err)
}
return token, nil
}
// FindTokenByID finds a token in the storage using its ID.
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")
}
// Get the token from storage
token, err := s.db.FindTokenByID(ctx, id)
if err != nil {
return token, fmt.Errorf("could not get token from storage: %w", err)
}
return token, 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
token := &models.Token{
ID: shortutil.GenerateRandomShort(TokenIDLength),
Name: user.Username + "'s token",
Value: TokenPrefix + shortutil.GenerateRandomShort(TokenLength),
User: user,
}
// Create the token in storage
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
}
// DeleteToken deletes a token from the storage.
func (s *TokenService) DeleteToken(ctx context.Context, token *models.Token) error {
// Delete the token from storage
err := s.db.DeleteToken(ctx, token)
if err != nil {
return fmt.Errorf("could not delete token from storage: %w", err)
}
return nil
}

View File

@ -90,7 +90,7 @@ func (s *UserService) AuthenticateUser(ctx context.Context, username string, pas
if err != nil {
// Even if the user does not exist, hash a password to waste time
// and not give away wether or not the user exists.
_, _ = passwords.HashPassword("r4nd0mpa55w0rd")
_, _ = passwords.HashPassword("r4ndom_passw0rd")
return &models.User{}, fmt.Errorf("failed to find user: %w", err)
}

View File

@ -13,17 +13,22 @@ import (
// MemoryStorage is a storage that stores everything in memory.
type MemoryStorage struct {
storage.Storage
shortMu sync.RWMutex
userMu sync.RWMutex
shortMap map[string]*models.Short
userMap map[string]*models.User
shortMu sync.RWMutex
userMu sync.RWMutex
tokenMu sync.RWMutex
shortMap map[string]*models.Short
userMap map[string]*models.User
tokenMap map[string]*models.Token
tokenIDMap map[string]*models.Token
}
// NewMemoryStorage creates a new MemoryStorage.
func NewMemoryStorage() *MemoryStorage {
return &MemoryStorage{
shortMap: make(map[string]*models.Short),
userMap: make(map[string]*models.User),
shortMap: make(map[string]*models.Short),
userMap: make(map[string]*models.User),
tokenMap: make(map[string]*models.Token),
tokenIDMap: make(map[string]*models.Token),
}
}
@ -136,3 +141,80 @@ func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error
return nil
}
func (s *MemoryStorage) FindToken(ctx context.Context, value string) (*models.Token, error) {
s.tokenMu.RLock()
defer s.tokenMu.RUnlock()
token, ok := s.tokenMap[value]
if !ok {
return token, errs.ErrTokenDoesNotExist
}
return token, nil
}
func (s *MemoryStorage) FindTokenByID(ctx context.Context, id string) (*models.Token, error) {
s.tokenMu.RLock()
defer s.tokenMu.RUnlock()
token, ok := s.tokenIDMap[id]
if !ok {
return token, errs.ErrTokenDoesNotExist
}
return token, nil
}
func (s *MemoryStorage) ListTokens(ctx context.Context, user *models.User) ([]*models.Token, error) {
s.tokenMu.RLock()
defer s.tokenMu.RUnlock()
tokens := []*models.Token{}
for _, token := range s.tokenMap {
if token.User != nil && token.User.Username == user.Username {
tokens = append(tokens, token)
}
}
return tokens, nil
}
func (s *MemoryStorage) CreateToken(ctx context.Context, token *models.Token) error {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
_, ok := s.tokenMap[token.Value]
if ok {
return errs.ErrTokenExists
}
_, ok = s.tokenIDMap[token.ID]
if ok {
return errs.ErrTokenExists
}
s.tokenMap[token.Value] = token
s.tokenIDMap[token.ID] = token
return nil
}
func (s *MemoryStorage) DeleteToken(ctx context.Context, token *models.Token) error {
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
_, ok := s.tokenMap[token.Value]
if !ok {
return errs.ErrTokenDoesNotExist
}
_, ok = s.tokenIDMap[token.ID]
if !ok {
return errs.ErrTokenDoesNotExist
}
delete(s.tokenMap, token.Value)
delete(s.tokenIDMap, token.ID)
return nil
}

View File

@ -0,0 +1,13 @@
package models
type Token struct {
// 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"`
// User is the user that created the token.
User *User `json:"-"`
}

View File

@ -26,4 +26,17 @@ type Storage interface {
CreateUser(ctx context.Context, user *models.User) error
// DeleteUser deletes a user and all their shorts from the storage.
DeleteUser(ctx context.Context, user *models.User) error
// Token Storage
// FindToken finds a token in the storage using its value.
FindToken(ctx context.Context, value string) (*models.Token, error)
// FindTokenByID finds a token in the storage using its ID.
FindTokenByID(ctx context.Context, id string) (*models.Token, error)
// 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
// DeleteToken deletes a token from the storage.
DeleteToken(ctx context.Context, token *models.Token) error
}