From 7052420b7939ce50521d125e3faf8552d0d2c1fc Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Mon, 21 Aug 2023 01:19:10 -0300 Subject: [PATCH] nice changes --- cmd/dev/dev.go | 18 +- cmd/serve/serve.go | 4 +- frontend/public/vite.svg | 1 - frontend/src/assets/react.svg | 1 - frontend/src/components/Container.tsx | 2 +- frontend/src/components/ItemBase.tsx | 88 +++++++ frontend/src/components/ItemList.tsx | 30 +++ frontend/src/components/NavButton.tsx | 6 +- frontend/src/components/Navbar.tsx | 19 +- frontend/src/components/SessionItem.tsx | 21 ++ frontend/src/components/ShortItem.tsx | 94 ++----- frontend/src/components/TokenItem.tsx | 13 + frontend/src/hooks/useAuth.tsx | 7 +- frontend/src/hooks/useOnUpdateItem.tsx | 73 ++++++ frontend/src/hooks/useSortedILoadedtems.tsx | 22 ++ frontend/src/pages/Index.tsx | 164 +++++++++++- frontend/src/pages/Login.tsx | 73 ++++-- frontend/src/pages/Sessions.tsx | 97 +++++--- frontend/src/pages/ShortDetails.tsx | 4 +- frontend/src/pages/Shorts.tsx | 72 ++---- frontend/src/pages/Signup.tsx | 75 ++++-- frontend/src/pages/Tokens.tsx | 81 ++++-- frontend/src/types.ts | 16 ++ frontend/src/util/fetchAPI.ts | 53 ++-- go.mod | 1 + go.sum | 2 + internal/errs/errors.go | 8 + internal/server/api/handler.go | 178 +++++++++---- internal/server/api/router.go | 5 + internal/server/errors.go | 24 ++ internal/server/middleware/auth.go | 263 ++++++++++++++++++-- internal/server/middleware/session.go | 4 +- internal/server/server.go | 2 +- internal/service/token/tokenservice.go | 102 ++++++++ internal/service/user/userservice.go | 2 +- internal/storage/memory/memory.go | 94 ++++++- internal/storage/models/token.go | 13 + internal/storage/storage.go | 13 + 38 files changed, 1384 insertions(+), 361 deletions(-) delete mode 100644 frontend/public/vite.svg delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/ItemBase.tsx create mode 100644 frontend/src/components/ItemList.tsx create mode 100644 frontend/src/components/SessionItem.tsx create mode 100644 frontend/src/components/TokenItem.tsx create mode 100644 frontend/src/hooks/useOnUpdateItem.tsx create mode 100644 frontend/src/hooks/useSortedILoadedtems.tsx create mode 100644 internal/service/token/tokenservice.go create mode 100644 internal/storage/models/token.go diff --git a/cmd/dev/dev.go b/cmd/dev/dev.go index 460bfee..88a6217 100644 --- a/cmd/dev/dev.go +++ b/cmd/dev/dev.go @@ -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) diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 0526aeb..2d97f64 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -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) diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/Container.tsx b/frontend/src/components/Container.tsx index 642a3a3..35d9e1c 100644 --- a/frontend/src/components/Container.tsx +++ b/frontend/src/components/Container.tsx @@ -2,7 +2,7 @@ import { FunctionComponent, PropsWithChildren } from "react" const Container: FunctionComponent = ({ children }) => { return ( -
+
{children}
) diff --git a/frontend/src/components/ItemBase.tsx b/frontend/src/components/ItemBase.tsx new file mode 100644 index 0000000..20c57fa --- /dev/null +++ b/frontend/src/components/ItemBase.tsx @@ -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 ( +
  • +
    {children}
    +
    +
    + {copyString && ( + + + {copied ? "Copied!" : "Copy"} + + )} + {doDelete && ( + + + {deleting ? "Are you sure?" : "Delete"} + + )} +
    +
    + {detailsPaage && ( + +
    + details + + +
    + + )} +
    +
    +
  • + ) +} + +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] +} diff --git a/frontend/src/components/ItemList.tsx b/frontend/src/components/ItemList.tsx new file mode 100644 index 0000000..2465df5 --- /dev/null +++ b/frontend/src/components/ItemList.tsx @@ -0,0 +1,30 @@ +import { FunctionComponent } from "react" + +// Function that has a generic parameter +const ItemList = , K extends keyof T>({ + items, + Item, + idKey, + deleteItem, +}: { + items: T[] + idKey: K + Item: FunctionComponent void }> + deleteItem: (key: T[K]) => () => void +}) => { + return ( +
      + {items.map((item) => ( + + ))} +
    + ) +} + +export default ItemList diff --git a/frontend/src/components/NavButton.tsx b/frontend/src/components/NavButton.tsx index 5de40c2..84a972c 100644 --- a/frontend/src/components/NavButton.tsx +++ b/frontend/src/components/NavButton.tsx @@ -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 ( {({ isActive }) => } diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index acafebd..0a7bafd 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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, })}> -
    +
    {/* Mobile menu button*/} @@ -51,11 +48,11 @@ export default function Navbar() { )}
    -
    +
    -
    +
    {navigation.map((item) => (
    -
    +
    {/* Profile dropdown */} {isAuthenticated && (
    - + Open user menu {user?.username} @@ -119,7 +116,7 @@ export default function Navbar() { "bg-white shadow": open, })}> {({ close }) => ( -
    +
    {navigation.map((item) => ( close()} diff --git a/frontend/src/components/SessionItem.tsx b/frontend/src/components/SessionItem.tsx new file mode 100644 index 0000000..a947e71 --- /dev/null +++ b/frontend/src/components/SessionItem.tsx @@ -0,0 +1,21 @@ +import { FunctionComponent } from "react" + +import { Session } from "../types" + +const SessionItem: FunctionComponent void }> = ({ + doDelete, + ...session +}) => { + return ( +
  • + {JSON.stringify(session)} + +
  • + ) +} + +export default SessionItem diff --git a/frontend/src/components/ShortItem.tsx b/frontend/src/components/ShortItem.tsx index 5cf6694..d82efaf 100644 --- a/frontend/src/components/ShortItem.tsx +++ b/frontend/src/components/ShortItem.tsx @@ -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 void }> = ({ name, url, @@ -19,27 +17,25 @@ const ShortItem: FunctionComponent 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 ( -
  • -
    -
    + +
  • + ) } 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] -} diff --git a/frontend/src/components/TokenItem.tsx b/frontend/src/components/TokenItem.tsx new file mode 100644 index 0000000..7654609 --- /dev/null +++ b/frontend/src/components/TokenItem.tsx @@ -0,0 +1,13 @@ +import { FunctionComponent } from "react" + +import { Token } from "../types" + +const TokenItem: FunctionComponent void }> = () => { + return ( +
  • + name +
  • + ) +} + +export default TokenItem diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index ec47dee..4a43571 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -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 => { const response = await fetchAPI("/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("/") } diff --git a/frontend/src/hooks/useOnUpdateItem.tsx b/frontend/src/hooks/useOnUpdateItem.tsx new file mode 100644 index 0000000..e147ed0 --- /dev/null +++ b/frontend/src/hooks/useOnUpdateItem.tsx @@ -0,0 +1,73 @@ +import { SetStateAction, useCallback, useEffect, useState } from "react" + +import fetchAPI from "../util/fetchAPI" + +export const useOnDelete = >( + apiPath: string, + idKey: keyof T, + setData: (value: SetStateAction) => 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 = >( + apiPath: string, + setData: (value: SetStateAction) => void +) => { + const [creating, setCreating] = useState(false) + const create = useCallback( + async (payload?: T) => { + // Avoid multiple creates + if (creating) return + setCreating(true) + + const response = await fetchAPI(`${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 +} diff --git a/frontend/src/hooks/useSortedILoadedtems.tsx b/frontend/src/hooks/useSortedILoadedtems.tsx new file mode 100644 index 0000000..b25bb09 --- /dev/null +++ b/frontend/src/hooks/useSortedILoadedtems.tsx @@ -0,0 +1,22 @@ +import { useEffect, useMemo, useState } from "react" + +import { useLoaderData } from "react-router-dom" + +const useSortedLoadedItems = ( + sort: (a: T, b: T) => number +) => { + const sourceDefault = useMemo(() => [], []) + const source = (useLoaderData() ?? sourceDefault) as T[] + + const [data, setData] = useState([...source].sort(sort)) + + // Auto update data when source changes + useEffect(() => { + setData([...source].sort(sort)) + }, [source, sort]) + + // Return the sorted data and a way to update it + return [data, setData] as const +} + +export default useSortedLoadedItems diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 4a98e1b..008e651 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -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(null) + const navigation = useNavigation() + const isShortening = navigation.formData?.get("url") != null + + const [name, setName] = useState("") + const handleNameChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/[^a-z0-9-_]/gi, "") + setName(value) + } + + const actionData = useActionData() as ActionResponse | undefined + + const [shorts, setShorts] = useState( + useMemo( + () => [ + { + name: "test", + url: "https://example.com", + }, + ], + [] + ) + ) + useEffect(() => { + if (actionData && "short" in actionData) { + setShorts((shorts) => [actionData.short, ...shorts]) + // If success, also reset the form and remove focus + form.current?.reset() + setName("") + for (const input of form.current?.elements || []) { + ;(input as HTMLElement).blur() + } + } + }, [actionData]) + + const deleteItem = useOnDelete("/shorts", "name", setShorts) + return ( -
    -

    hello

    -
    + <> +
    +
    + + + {actionData && "error" in actionData ? ( +

    + {actionData.error} +

    + ) : null} + +
    +
    + +
    + ) } + +export const action: LoaderFunction = async ({ + request, +}): Promise => { + if (request.method !== "POST") { + return { error: "Invalid request method" } + } + + const formData = await request.formData() + const url = formData.get("url") as string | null + const name = formData.get("name") as string | null + + if (!url) { + return { error: "You must provide a URL to shorten" } + } + + const body: { url: string; name?: string } = { url } + if (name) { + body.name = name + } + + const res = await fetchAPI("/shorts", { + method: "POST", + body: JSON.stringify(body), + }) + + if (!res.data) { + return { error: "Something went wrong" } + } + if (res.ok) { + return { short: res.data as Short } + } + + return { error: res.data } +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 47fc849..0a1db7b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 ( <> -
    -

    You must log in to view the page at {from}

    - -
    - - {" "} - {" "} - +
    + + Log in {actionData && actionData.error ? ( -

    {actionData.error}

    +

    + {actionData.error} +

    ) : null} + + + + + + {`Don't have an account? `} + + Sign up + + ) diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx index 9e9006e..f626641 100644 --- a/frontend/src/pages/Sessions.tsx +++ b/frontend/src/pages/Sessions.tsx @@ -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( + 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 ( +
      + {data.map((session) => ( + + ))} +
    + ) + } + const NoSessions = () => { + return ( +
    No sessions yet
    + ) + } + return ( <>
    -
      - {data.map((s) => ( -
    • {s.title}
    • - ))} -
    + {data.length > 0 ? : } ) } +export const loader: LoaderFunction = async (args) => { + const resp = await protectedLoader(args) + if (resp) return resp + + const data = await fetchAPI("/sessions") + if (data.ok) { + return data.data + } + return redirect("/lgo") +} + Component.displayName = "SessionsPage" diff --git a/frontend/src/pages/ShortDetails.tsx b/frontend/src/pages/ShortDetails.tsx index dd9e73c..125c39d 100644 --- a/frontend/src/pages/ShortDetails.tsx +++ b/frontend/src/pages/ShortDetails.tsx @@ -22,9 +22,9 @@ export const loader: LoaderFunction = async (args) => { const resp = await protectedLoader(args) if (resp) return resp - const data = await fetchAPI("/shorts") + const data = await fetchAPI(`/shorts/${args.params.name}`) if (data.ok) { - return data.data?.find((short) => short.name === args.params.name) + return data.data } return redirect("/lgo") } diff --git a/frontend/src/pages/Shorts.tsx b/frontend/src/pages/Shorts.tsx index 5e6e620..a7d9afc 100644 --- a/frontend/src/pages/Shorts.tsx +++ b/frontend/src/pages/Shorts.tsx @@ -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( + 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 ( -
      - {data.map((short) => ( - - ))} -
    + ) } const NoShorts = () => { - return
    No Shorts
    + return ( +
    No shorts yet
    + ) } return ( <>
    - {data.length > 0 ? : } + {shorts.length > 0 ? : } ) } diff --git a/frontend/src/pages/Signup.tsx b/frontend/src/pages/Signup.tsx index d972600..f25911b 100644 --- a/frontend/src/pages/Signup.tsx +++ b/frontend/src/pages/Signup.tsx @@ -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 ( <> -
    -

    You must log in to view the page at {from}

    - -
    - - {" "} - {" "} - +
    + + Sign up {actionData && actionData.error ? ( -

    {actionData.error}

    +

    + {actionData.error} +

    ) : null} + + + + + + {`Have an account? `} + + Log in + + ) } -Component.displayName = "LoginPage" +Component.displayName = "SignupPage" diff --git a/frontend/src/pages/Tokens.tsx b/frontend/src/pages/Tokens.tsx index 2ee69a0..27e989d 100644 --- a/frontend/src/pages/Tokens.tsx +++ b/frontend/src/pages/Tokens.tsx @@ -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( + useMemo(() => (a, b) => b.createdAt.localeCompare(a.createdAt), []) + ) + + const deleteItem = useOnDelete("/tokens", "id", setData) + const [creating, createItem] = useOnCreate("/tokens", setData) + + const Tokens: FunctionComponent = () => { + return ( +
      + {data.map((item) => ( + + ))} +
    + ) + } + const NoTokens = () => { + return ( +
    No tokens yet
    + ) + } + return ( <>
    -
      - {data.map((s) => ( -
    • {s.name}
    • - ))} -
    + + {data.length > 0 ? : } ) } +export const loader: LoaderFunction = async (args) => { + const resp = await protectedLoader(args) + if (resp) return resp + + // const data = await fetchAPI("/sessions") + const data = { ok: true, data: [] } + if (data.ok) { + return data.data + } + return redirect("/lgo") +} + Component.displayName = "TokensPage" diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a6568c6..c279311 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 +} diff --git a/frontend/src/util/fetchAPI.ts b/frontend/src/util/fetchAPI.ts index 74bef73..f281665 100644 --- a/frontend/src/util/fetchAPI.ts +++ b/frontend/src/util/fetchAPI.ts @@ -1,31 +1,54 @@ +type ErrorResponse = { + status: string + error: string +} + +type Result = + | { + data: T | string + ok: true + } + | { + data: string + ok: false + } + // Fetch function that automatically points to the API URL export default async function ( path: string, args: Parameters[1] = {} -): Promise<{ data: T | null; ok: boolean }> { +): Promise> { 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, + } } diff --git a/go.mod b/go.mod index bcfd336..888cea5 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e7f2672..e1e677e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/errs/errors.go b/internal/errs/errors.go index 7e32f9a..d3a6849 100644 --- a/internal/errs/errors.go +++ b/internal/errs/errors.go @@ -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 { diff --git a/internal/server/api/handler.go b/internal/server/api/handler.go index bea5f42..5d01d93 100644 --- a/internal/server/api/handler.go +++ b/internal/server/api/handler.go @@ -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 } diff --git a/internal/server/api/router.go b/internal/server/api/router.go index 11f7001..fd6f9e1 100644 --- a/internal/server/api/router.go +++ b/internal/server/api/router.go @@ -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 diff --git a/internal/server/errors.go b/internal/server/errors.go index a16576a..6f7e1e2 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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)) +} diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go index 0ef12d2..6d9f3be 100644 --- a/internal/server/middleware/auth.go +++ b/internal/server/middleware/auth.go @@ -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 } diff --git a/internal/server/middleware/session.go b/internal/server/middleware/session.go index f76f9b5..15b555b 100644 --- a/internal/server/middleware/session.go +++ b/internal/server/middleware/session.go @@ -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. diff --git a/internal/server/server.go b/internal/server/server.go index 59a6459..2a8d9fb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/service/token/tokenservice.go b/internal/service/token/tokenservice.go new file mode 100644 index 0000000..d0dde4e --- /dev/null +++ b/internal/service/token/tokenservice.go @@ -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 +} diff --git a/internal/service/user/userservice.go b/internal/service/user/userservice.go index 5139647..3351be0 100644 --- a/internal/service/user/userservice.go +++ b/internal/service/user/userservice.go @@ -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) } diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 822c71f..05ea9ac 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -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 +} diff --git a/internal/storage/models/token.go b/internal/storage/models/token.go new file mode 100644 index 0000000..adab7b5 --- /dev/null +++ b/internal/storage/models/token.go @@ -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:"-"` +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 6f87e06..34aa367 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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 }