nice changes
This commit is contained in:
parent
8a1a511a9b
commit
7052420b79
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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]
|
||||
}
|
|
@ -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
|
|
@ -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} />}
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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("/")
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
1
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
|
||||
)
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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:"-"`
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user