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"
|
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
|
||||||
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
|
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
|
||||||
shortservice "git.maronato.dev/maronato/goshort/internal/service/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"
|
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
||||||
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
|
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/cors"
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
@ -76,6 +77,7 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
|
||||||
storage := shared.InitStorage(cfg)
|
storage := shared.InitStorage(cfg)
|
||||||
shortService := shortservice.NewShortService(storage)
|
shortService := shortservice.NewShortService(storage)
|
||||||
userService := userservice.NewUserService(cfg, storage)
|
userService := userservice.NewUserService(cfg, storage)
|
||||||
|
tokenService := tokenservice.NewTokenService(storage)
|
||||||
|
|
||||||
// Create handlers
|
// Create handlers
|
||||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
||||||
|
@ -93,11 +95,15 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
|
||||||
// Configure app routes
|
// Configure app routes
|
||||||
server.Mux.Route("/api", func(r chi.Router) {
|
server.Mux.Route("/api", func(r chi.Router) {
|
||||||
// Set CORS headers for API routes in development mode
|
// 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(cors.Handler(cors.Options{
|
||||||
r.Use(middleware.SetHeader("Access-Control-Allow-Methods", "*"))
|
AllowedOrigins: []string{
|
||||||
r.Use(middleware.SetHeader("Access-Control-Allow-Headers", "*"))
|
"http://" + net.JoinHostPort(cfg.Host, cfg.UIPort),
|
||||||
r.Use(middleware.SetHeader("Access-Control-Allow-Credentials", "true"))
|
},
|
||||||
r.Use(servermiddleware.Auth(userService))
|
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
r.Use(servermiddleware.Auth(userService, tokenService))
|
||||||
r.Mount("/", apiRouter)
|
r.Mount("/", apiRouter)
|
||||||
})
|
})
|
||||||
server.Mux.Mount("/healthz", healthcheckRouter)
|
server.Mux.Mount("/healthz", healthcheckRouter)
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
|
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
|
||||||
staticssterver "git.maronato.dev/maronato/goshort/internal/server/static"
|
staticssterver "git.maronato.dev/maronato/goshort/internal/server/static"
|
||||||
shortservice "git.maronato.dev/maronato/goshort/internal/service/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"
|
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
||||||
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
|
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
@ -50,6 +51,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
|
||||||
storage := shared.InitStorage(cfg)
|
storage := shared.InitStorage(cfg)
|
||||||
shortService := shortservice.NewShortService(storage)
|
shortService := shortservice.NewShortService(storage)
|
||||||
userService := userservice.NewUserService(cfg, storage)
|
userService := userservice.NewUserService(cfg, storage)
|
||||||
|
tokenService := tokenservice.NewTokenService(storage)
|
||||||
|
|
||||||
// Create handlers
|
// Create handlers
|
||||||
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
apiHandler := apiserver.NewAPIHandler(shortService, userService)
|
||||||
|
@ -68,7 +70,7 @@ func exec(ctx context.Context, cfg *config.Config) error {
|
||||||
|
|
||||||
// Configure app routes
|
// Configure app routes
|
||||||
server.Mux.Route("/api", func(r chi.Router) {
|
server.Mux.Route("/api", func(r chi.Router) {
|
||||||
r.Use(servermiddleware.Auth(userService))
|
r.Use(servermiddleware.Auth(userService, tokenService))
|
||||||
r.Mount("/", apiRouter)
|
r.Mount("/", apiRouter)
|
||||||
})
|
})
|
||||||
server.Mux.Mount("/healthz", healthcheckRouter)
|
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 }) => {
|
const Container: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</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 classNames from "classnames"
|
||||||
import { NavLink, useSearchParams } from "react-router-dom"
|
import { NavLink, useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useIsAuthenticated } from "../hooks/useAuth"
|
||||||
|
|
||||||
const NavButton: FunctionComponent<{
|
const NavButton: FunctionComponent<{
|
||||||
text: string
|
text: string
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
@ -29,9 +31,11 @@ const NavButton: FunctionComponent<{
|
||||||
// to the page they were on before they logged in.
|
// to the page they were on before they logged in.
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const from = searchParams.get("from")
|
const from = searchParams.get("from")
|
||||||
|
const isAuthenticated = useIsAuthenticated()
|
||||||
|
|
||||||
if (link) {
|
if (link) {
|
||||||
const linkWithFrom = from ? `${link}?from=${from}` : link
|
const linkWithFrom =
|
||||||
|
from && !isAuthenticated ? `${link}?from=${from}` : link
|
||||||
return (
|
return (
|
||||||
<NavLink to={linkWithFrom}>
|
<NavLink to={linkWithFrom}>
|
||||||
{({ isActive }) => <Content active={isActive} />}
|
{({ isActive }) => <Content active={isActive} />}
|
||||||
|
|
|
@ -23,10 +23,7 @@ export default function Navbar() {
|
||||||
{ name: "Tokens", href: "tkn" },
|
{ name: "Tokens", href: "tkn" },
|
||||||
{ name: "Sessions", href: "ses" },
|
{ name: "Sessions", href: "ses" },
|
||||||
]
|
]
|
||||||
const unauthed = [
|
const unauthed = [{ name: "Signup", href: "sgn" }]
|
||||||
{ name: "Login", href: "lgn" },
|
|
||||||
{ name: "Signup", href: "sgn" },
|
|
||||||
]
|
|
||||||
return isAuthenticated ? authed : unauthed
|
return isAuthenticated ? authed : unauthed
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
|
@ -38,7 +35,7 @@ export default function Navbar() {
|
||||||
className={classNames("mx-auto max-w-7xl px-2 sm:px-6 lg:px-8", {
|
className={classNames("mx-auto max-w-7xl px-2 sm:px-6 lg:px-8", {
|
||||||
"bg-white": open,
|
"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">
|
<div className="absolute inset-y-0 left-0 flex items-center sm:hidden">
|
||||||
{/* Mobile menu button*/}
|
{/* 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">
|
<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>
|
</Disclosure.Button>
|
||||||
</div>
|
</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">
|
<div className="flex flex-shrink-0 items-center">
|
||||||
<NavButton text="GoShort" link="/" />
|
<NavButton text="GoShort" link="/" />
|
||||||
</div>
|
</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">
|
<div className="flex space-x-4">
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<NavButton
|
<NavButton
|
||||||
|
@ -67,18 +64,18 @@ export default function Navbar() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Profile dropdown */}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Menu as="div" className="relative ml-3">
|
<Menu as="div" className="relative ml-3">
|
||||||
<div>
|
<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="absolute -inset-1.5" />
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
<span className="my-auto">{user?.username}</span>
|
<span className="my-auto">{user?.username}</span>
|
||||||
<span className="my-auto">
|
<span className="my-auto">
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
className="h-4 w-4 ml-2"
|
className="h-4 w-4 ml-1"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
@ -119,7 +116,7 @@ export default function Navbar() {
|
||||||
"bg-white shadow": open,
|
"bg-white shadow": open,
|
||||||
})}>
|
})}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<div className="space-y-1">
|
<div className="w-full space-y-1">
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
onClick={() => close()}
|
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 {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
ChevronRightIcon,
|
|
||||||
ClipboardIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from "@heroicons/react/24/outline"
|
} from "@heroicons/react/24/outline"
|
||||||
import { Link } from "react-router-dom"
|
|
||||||
|
|
||||||
import { Short } from "../types"
|
import { Short } from "../types"
|
||||||
|
|
||||||
|
import ItemBase from "./ItemBase"
|
||||||
|
|
||||||
const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
|
@ -19,27 +17,25 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
const origin = location.origin
|
const origin = location.origin
|
||||||
const host = "marona.to"
|
const host = "marona.to"
|
||||||
|
|
||||||
const maxSize = 190
|
const maxSize = 120
|
||||||
url = `${url}/fdfbdsjhbfsjhbfsjdhbfdsjhfbsjdhbfjshdbfjshdbfjshbfhsjbfdjhfbshjfbsdjhfbsjhfbsjdhfbsjhdfbsjdfhbdnfjkdsnfsjkfnsdkjfndskjfnskjfnskdjfnskjdfdfbdsjhbfsjhbfsjdhbfdsjhfbsjdhbfjshdbfjshdbfjshbfhsjbfdjhfbshjfbsdjhfbsjhfbsjdhfbsjhdfbsjdfhbdnfjkdsnfsjkfnsdkjfndskjfnskjfnskdjfnskjd`
|
|
||||||
const displayURL =
|
const displayURL =
|
||||||
url.length >= maxSize - 3 ? `${url.slice(0, maxSize)}...` : url
|
url.length >= maxSize - 3 ? `${url.slice(0, maxSize)}...` : url
|
||||||
|
|
||||||
const shortNameURL = `${origin}/${name}`
|
const shortNameURL = `${origin}/${name}`
|
||||||
|
|
||||||
const [copied, copy] = useClipboardTimeout(shortNameURL)
|
|
||||||
|
|
||||||
const [deleting, triggerDelete] = useDoubleclickDelete(doDelete)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex justify-between py-5 px-6 group duration-200 transition-colors bg-white">
|
<ItemBase
|
||||||
<div className="min-w-0 grid md:grid-cols-10 md:grid-rows-1 w-full grid-flow-row md:grid-flow-col">
|
copyString={shortNameURL}
|
||||||
<div className="col-span-5 md:col-span-3 lg:col-span-2 my-auto flex flex-col order-1">
|
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
|
<a
|
||||||
href={shortNameURL}
|
href={shortNameURL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex flex-row font-semibold leading-6 text-blue-600 hover:text-blue-500 transition-all duration-200">
|
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-sm font-light text-blue-500">{host}/</span>
|
||||||
<span className="text-base">{name}</span>
|
<span className="text-base">{name}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -47,25 +43,11 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
|
<ArrowTopRightOnSquareIcon className="w-3 h-3 mb-2" />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</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>
|
<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">
|
||||||
<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">
|
|
||||||
<ArrowRightIcon className="w-6 h-6 text-green-500 rotate-90 md:rotate-0" />
|
<ArrowRightIcon className="w-6 h-6 text-green-500 rotate-90 md:rotate-0" />
|
||||||
</div>
|
</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
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
title={url}
|
title={url}
|
||||||
|
@ -86,58 +68,10 @@ const ShortItem: FunctionComponent<Short & { doDelete: () => void }> = ({
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-400">
|
<p className="mt-1 text-xs leading-5 text-slate-400">
|
||||||
Last viewed <time dateTime="2023-01-23T13:23Z">3h ago</time>
|
Last viewed <time dateTime="2023-01-23T13:23Z">3h ago</time>
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</ItemBase>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ShortItem
|
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) {
|
if (response.ok) {
|
||||||
this.user = response.data
|
this.user = response.data as User
|
||||||
this.isAuthenticated = true
|
this.isAuthenticated = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,7 @@ export const indexLoader: LoaderFunction =
|
||||||
async (): Promise<IndexLoaderData> => {
|
async (): Promise<IndexLoaderData> => {
|
||||||
const response = await fetchAPI<User>("/me")
|
const response = await fetchAPI<User>("/me")
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
AuthProvider.user = response.data
|
AuthProvider.user = response.data as User
|
||||||
AuthProvider.isAuthenticated = true
|
AuthProvider.isAuthenticated = true
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
@ -131,9 +131,6 @@ export const signupLoader = loginLoader
|
||||||
|
|
||||||
export const logoutLoader: LoaderFunction = async () => {
|
export const logoutLoader: LoaderFunction = async () => {
|
||||||
await AuthProvider.logout()
|
await AuthProvider.logout()
|
||||||
setTimeout(() => {
|
|
||||||
location.reload()
|
|
||||||
}, 100)
|
|
||||||
return redirect("/")
|
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 = () => {
|
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 (
|
return (
|
||||||
<div className="pt-20 pb-10">
|
<>
|
||||||
<p>hello</p>
|
<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>
|
</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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
Link,
|
||||||
useActionData,
|
useActionData,
|
||||||
useLocation,
|
|
||||||
useNavigation,
|
useNavigation,
|
||||||
|
useSearchParams,
|
||||||
} from "react-router-dom"
|
} from "react-router-dom"
|
||||||
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const location = useLocation()
|
const [params] = useSearchParams()
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const from = params.get("from") || "/"
|
const from = params.get("from") || "/"
|
||||||
|
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const isLoggingIn = navigation.formData?.get("username") != null
|
const isSigningUp = navigation.formData?.get("username") != null
|
||||||
|
|
||||||
const actionData = useActionData() as { error: string } | undefined
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Login" />
|
<Header title="" />
|
||||||
<p>You must log in to view the page at {from}</p>
|
<Form
|
||||||
|
method="post"
|
||||||
<Form method="post" replace>
|
replace
|
||||||
<input type="hidden" name="redirectTo" value={from} />
|
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
||||||
<label>
|
<span className="text-3xl font-bold text-center mb-4">Log in</span>
|
||||||
Username: <input name="username" />
|
|
||||||
</label>{" "}
|
|
||||||
<label>
|
|
||||||
Password: <input type="password" name="password" />
|
|
||||||
</label>{" "}
|
|
||||||
<button type="submit" disabled={isLoggingIn}>
|
|
||||||
{isLoggingIn ? "Logging in..." : "Login"}
|
|
||||||
</button>
|
|
||||||
{actionData && actionData.error ? (
|
{actionData && actionData.error ? (
|
||||||
<p style={{ color: "red" }}>{actionData.error}</p>
|
<p className="text-red-500 text-center font-medium">
|
||||||
|
{actionData.error}
|
||||||
|
</p>
|
||||||
) : null}
|
) : 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>
|
</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 Header from "../components/Header"
|
||||||
|
import SessionItem from "../components/SessionItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
||||||
type Session = {
|
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||||
id: number
|
import { Session } from "../types"
|
||||||
title: string
|
import fetchAPI from "../util/fetchAPI"
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Sessions" />
|
<Header title="Sessions" />
|
||||||
<ul>
|
{data.length > 0 ? <Sessions /> : <NoSessions />}
|
||||||
{data.map((s) => (
|
|
||||||
<li key={s.id}>{s.title}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
Component.displayName = "SessionsPage"
|
||||||
|
|
|
@ -22,9 +22,9 @@ export const loader: LoaderFunction = async (args) => {
|
||||||
const resp = await protectedLoader(args)
|
const resp = await protectedLoader(args)
|
||||||
if (resp) return resp
|
if (resp) return resp
|
||||||
|
|
||||||
const data = await fetchAPI<Short[]>("/shorts")
|
const data = await fetchAPI<Short>(`/shorts/${args.params.name}`)
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
return data.data?.find((short) => short.name === args.params.name)
|
return data.data
|
||||||
}
|
}
|
||||||
return redirect("/lgo")
|
return redirect("/lgo")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,79 +1,43 @@
|
||||||
import {
|
import { FunctionComponent, useMemo } from "react"
|
||||||
FunctionComponent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react"
|
|
||||||
|
|
||||||
import {
|
import { LoaderFunction, redirect } from "react-router-dom"
|
||||||
LoaderFunction,
|
|
||||||
redirect,
|
|
||||||
useLoaderData,
|
|
||||||
useRevalidator,
|
|
||||||
} from "react-router-dom"
|
|
||||||
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
import ItemList from "../components/ItemList"
|
||||||
import ShortItem from "../components/ShortItem"
|
import ShortItem from "../components/ShortItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
import { useOnDelete } from "../hooks/useOnUpdateItem"
|
||||||
|
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||||
import { Short } from "../types"
|
import { Short } from "../types"
|
||||||
import fetchAPI from "../util/fetchAPI"
|
import fetchAPI from "../util/fetchAPI"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const sourceDataDefault = useMemo(() => [], [])
|
const [shorts, setShorts] = useSortedLoadedItems<Short>(
|
||||||
const sourceData = (useLoaderData() ?? sourceDataDefault) as Short[]
|
useMemo(() => (a, b) => a.name.localeCompare(b.name), [])
|
||||||
|
|
||||||
const [data, setData] = useState(
|
|
||||||
sourceData
|
|
||||||
.map((short) => short)
|
|
||||||
.sort((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 deleteItem = useOnDelete("/shorts", "name", setShorts)
|
||||||
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 Shorts: FunctionComponent = () => {
|
const Shorts: FunctionComponent = () => {
|
||||||
return (
|
return (
|
||||||
<ul
|
<ItemList
|
||||||
role="list"
|
items={shorts}
|
||||||
className="divide-y divide-gray-100 rounded-lg shadow-md overflow-hidden">
|
Item={ShortItem}
|
||||||
{data.map((short) => (
|
idKey="name"
|
||||||
<ShortItem
|
deleteItem={deleteItem}
|
||||||
{...short}
|
|
||||||
doDelete={deleteShort(short.name)}
|
|
||||||
key={short.name}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const NoShorts = () => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Shorts" />
|
<Header title="Shorts" />
|
||||||
{data.length > 0 ? <Shorts /> : <NoShorts />}
|
{shorts.length > 0 ? <Shorts /> : <NoShorts />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +1,79 @@
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
Link,
|
||||||
useActionData,
|
useActionData,
|
||||||
useLocation,
|
|
||||||
useNavigation,
|
useNavigation,
|
||||||
|
useSearchParams,
|
||||||
} from "react-router-dom"
|
} from "react-router-dom"
|
||||||
|
|
||||||
import Header from "../components/Header"
|
import Header from "../components/Header"
|
||||||
|
|
||||||
export function Component() {
|
export function Component() {
|
||||||
const location = useLocation()
|
const [params] = useSearchParams()
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const from = params.get("from") || "/"
|
const from = params.get("from") || "/"
|
||||||
|
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const isLoggingIn = navigation.formData?.get("username") != null
|
const isSigningUp = navigation.formData?.get("username") != null
|
||||||
|
|
||||||
const actionData = useActionData() as { error: string } | undefined
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Signup" />
|
<Header title="" />
|
||||||
<p>You must log in to view the page at {from}</p>
|
<Form
|
||||||
|
method="post"
|
||||||
<Form method="post" replace>
|
replace
|
||||||
<input type="hidden" name="redirectTo" value={from} />
|
className="flex flex-col mx-auto gap-y-4 rounded-lg shadow-md p-8">
|
||||||
<label>
|
<span className="text-3xl font-bold text-center mb-4">Sign up</span>
|
||||||
Username: <input name="username" />
|
|
||||||
</label>{" "}
|
|
||||||
<label>
|
|
||||||
Password: <input type="password" name="password" />
|
|
||||||
</label>{" "}
|
|
||||||
<button type="submit" disabled={isLoggingIn}>
|
|
||||||
{isLoggingIn ? "Logging in..." : "Login"}
|
|
||||||
</button>
|
|
||||||
{actionData && actionData.error ? (
|
{actionData && actionData.error ? (
|
||||||
<p style={{ color: "red" }}>{actionData.error}</p>
|
<p className="text-red-500 text-center font-medium">
|
||||||
|
{actionData.error}
|
||||||
|
</p>
|
||||||
) : null}
|
) : 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>
|
</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 Header from "../components/Header"
|
||||||
|
import TokenItem from "../components/TokenItem"
|
||||||
import { protectedLoader } from "../hooks/useAuth"
|
import { protectedLoader } from "../hooks/useAuth"
|
||||||
|
import { useOnCreate, useOnDelete } from "../hooks/useOnUpdateItem"
|
||||||
type Token = {
|
import useSortedLoadedItems from "../hooks/useSortedILoadedtems"
|
||||||
id: number
|
import { Token } from "../types"
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component() {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Tokens" />
|
<Header title="Tokens" />
|
||||||
<ul>
|
<button
|
||||||
{data.map((s) => (
|
disabled={creating}
|
||||||
<li key={s.id}>{s.name}</li>
|
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">
|
||||||
</ul>
|
<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"
|
Component.displayName = "TokensPage"
|
||||||
|
|
|
@ -6,3 +6,19 @@ export type Short = {
|
||||||
name: string
|
name: string
|
||||||
url: 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
|
// Fetch function that automatically points to the API URL
|
||||||
export default async function <T>(
|
export default async function <T>(
|
||||||
path: string,
|
path: string,
|
||||||
args: Parameters<typeof fetch>[1] = {}
|
args: Parameters<typeof fetch>[1] = {}
|
||||||
): Promise<{ data: T | null; ok: boolean }> {
|
): Promise<Result<T>> {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
args.credentials = "include"
|
args.credentials = "include"
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
let response: Response
|
||||||
`${import.meta.env.VITE_API_URL || ""}${path}`,
|
|
||||||
args
|
|
||||||
)
|
|
||||||
|
|
||||||
let responseData: T | null = null
|
|
||||||
try {
|
try {
|
||||||
responseData = await response.json()
|
response = await fetch(`${import.meta.env.VITE_API_URL || ""}${path}`, args)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
responseData = null
|
console.error(e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: (e as Error).message,
|
||||||
|
ok: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the response was not ok
|
const responseClone = response.clone()
|
||||||
if (!response.ok) {
|
const dataOrString = await response.json().catch(() => responseClone.text())
|
||||||
console.error(response.statusText)
|
|
||||||
|
|
||||||
return { data: responseData, ok: false }
|
if (response.ok) {
|
||||||
|
return {
|
||||||
|
data: dataOrString,
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// on a successfull response, return the data
|
return {
|
||||||
return { data: responseData, ok: true }
|
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 (
|
require (
|
||||||
github.com/ajg/form v1.5.1 // indirect
|
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
|
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/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 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
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 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||||
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
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=
|
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
|
||||||
|
|
|
@ -26,6 +26,14 @@ var (
|
||||||
ErrInvalidUsernameOrPassword = errors.New("invalid username or password")
|
ErrInvalidUsernameOrPassword = errors.New("invalid username or password")
|
||||||
// ErrRegistrationDisabled
|
// ErrRegistrationDisabled
|
||||||
ErrRegistrationDisabled = errors.New("registration is disabled")
|
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 {
|
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) {
|
func (h *APIHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Get user from context
|
// Get user from context
|
||||||
user, ok := servermiddleware.UserFromCtx(ctx)
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respond with the user
|
// Respond with the user
|
||||||
|
@ -46,15 +44,17 @@ func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Get user from context
|
// Get user from context
|
||||||
user, ok := servermiddleware.UserFromCtx(ctx)
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the user
|
// Delete the user
|
||||||
err := h.users.DeleteUser(ctx, user)
|
err := h.users.DeleteUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout and return
|
// Logout and return
|
||||||
|
@ -73,7 +73,7 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
var login *loginForm
|
var login *loginForm
|
||||||
|
|
||||||
if err := render.DecodeJSON(r.Body, &login); err != nil {
|
if err := render.DecodeJSON(r.Body, &login); err != nil {
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -83,20 +83,20 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
|
||||||
// If the username or password are wrong, return invalid username/password
|
// 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) {
|
} else if errors.Is(err, errs.ErrInvalidUser) {
|
||||||
// If the request was invalid, return bad request
|
// If the request was invalid, return bad request
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
// Else, server error
|
// Else, server error
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login user
|
// Login user
|
||||||
servermiddleware.LoginUser(r.Context(), user)
|
servermiddleware.LoginUser(ctx, user, r)
|
||||||
|
|
||||||
// Render the response
|
// Render the response
|
||||||
render.Status(r, http.StatusOK)
|
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 {
|
if err := render.DecodeJSON(r.Body, &form); err != nil {
|
||||||
err = fmt.Errorf("failed to parse form: %w", err)
|
err = fmt.Errorf("failed to parse form: %w", err)
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -138,9 +138,9 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
// Hash the password into the user
|
// Hash the password into the user
|
||||||
if err := user.SetPassword(pass); err != nil {
|
if err := user.SetPassword(pass); err != nil {
|
||||||
if errors.Is(err, errs.ErrInvalidUsernameOrPassword) {
|
if errors.Is(err, errs.ErrInvalidUsernameOrPassword) {
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -150,11 +150,11 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
||||||
err := h.users.CreateUser(ctx, user)
|
err := h.users.CreateUser(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
|
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) {
|
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
|
||||||
server.RenderRender(w, r, server.ErrForbidden(err))
|
server.RenderForbidden(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -172,27 +172,27 @@ func (h *APIHandler) CreateShort(w http.ResponseWriter, r *http.Request) {
|
||||||
var short *models.Short
|
var short *models.Short
|
||||||
|
|
||||||
if err := render.DecodeJSON(r.Body, &short); err != nil {
|
if err := render.DecodeJSON(r.Body, &short); err != nil {
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from context
|
// Get user from context
|
||||||
user, ok := servermiddleware.UserFromCtx(ctx)
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure user is set
|
// Set the user
|
||||||
short.User = user
|
short.User = user
|
||||||
|
|
||||||
// Shorten URL
|
// Shorten URL
|
||||||
short, err := h.shorts.Shorten(ctx, short)
|
short, err := h.shorts.Shorten(ctx, short)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
if errors.Is(err, errs.ErrShortExists) || errors.Is(err, errs.ErrInvalidShort) {
|
||||||
server.RenderRender(w, r, server.ErrBadRequest(err))
|
server.RenderBadRequest(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
@ -207,15 +207,15 @@ func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
|
||||||
// Get user from context
|
// Get user from context
|
||||||
user, ok := servermiddleware.UserFromCtx(ctx)
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get shorts
|
// Get shorts
|
||||||
shorts, err := h.shorts.ListShorts(ctx, user)
|
shorts, err := h.shorts.ListShorts(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -225,9 +225,108 @@ func (h *APIHandler) ListShorts(w http.ResponseWriter, r *http.Request) {
|
||||||
render.JSON(w, r, shorts)
|
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) {
|
func (h *APIHandler) DeleteShort(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
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
|
// Get short name from request
|
||||||
name := chi.URLParam(r, "short")
|
name := chi.URLParam(r, "short")
|
||||||
|
|
||||||
|
@ -236,36 +335,27 @@ func (h *APIHandler) DeleteShort(w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the short doesn't exist or is invalid, return not found
|
// If the short doesn't exist or is invalid, return not found
|
||||||
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
|
||||||
server.RenderRender(w, r, server.ErrNotFound(err))
|
server.RenderNotFound(w, r, err)
|
||||||
} else {
|
} else {
|
||||||
server.RenderRender(w, r, server.ErrServerError(err))
|
server.RenderServerError(w, r, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from context
|
// Get user from context
|
||||||
user, ok := servermiddleware.UserFromCtx(ctx)
|
user, ok := h.findUserOrRespond(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
server.RenderRender(w, r, server.ErrServerError(errs.ErrInvalidUser))
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the session user does not match the short's user,
|
// If the session user does not match the short's user,
|
||||||
// return forbidden.
|
// return forbidden.
|
||||||
if user.Username != short.User.Username {
|
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
|
return short, true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,12 @@ func NewAPIRouter(h *APIHandler) http.Handler {
|
||||||
// Shorts routes
|
// Shorts routes
|
||||||
r.Get("/shorts", h.ListShorts)
|
r.Get("/shorts", h.ListShorts)
|
||||||
r.Post("/shorts", h.CreateShort)
|
r.Post("/shorts", h.CreateShort)
|
||||||
|
r.Get("/shorts/{short}", h.FindShort)
|
||||||
r.Delete("/shorts/{short}", h.DeleteShort)
|
r.Delete("/shorts/{short}", h.DeleteShort)
|
||||||
|
|
||||||
|
// Sessions routes
|
||||||
|
r.Get("/sessions", h.ListSessions)
|
||||||
|
r.Delete("/sessions/{id}", h.DeleteSession)
|
||||||
})
|
})
|
||||||
|
|
||||||
return mux
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"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"
|
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
|
||||||
"git.maronato.dev/maronato/goshort/internal/storage/models"
|
"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{}
|
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 func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Get session from context
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
session := SessionFromCtx(ctx)
|
|
||||||
|
|
||||||
// If no session exists, call the next handler
|
// Authenticate user
|
||||||
if !session.Exists(ctx, sessionUserKey) {
|
user, err := authenticateViaToken(r, tokenService)
|
||||||
|
if err != nil {
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get username from session
|
|
||||||
username := session.GetString(ctx, sessionUserKey)
|
|
||||||
// Get user from storage
|
|
||||||
user, err := userService.FindUser(ctx, username)
|
|
||||||
if err != nil {
|
|
||||||
// Since we couldn't find the user, remove the key from session and call the next handler
|
|
||||||
LogoutUser(ctx)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user to context
|
// Add user to context
|
||||||
|
@ -68,14 +94,201 @@ func UserFromCtx(ctx context.Context) (*models.User, bool) {
|
||||||
return user, ok
|
return user, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoginUser(ctx context.Context, user *models.User) {
|
func SessionDataFromCtx(manager *scs.SessionManager, sessionCtx context.Context) *AuthSessionData {
|
||||||
session := SessionFromCtx(ctx)
|
// 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) {
|
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{}
|
type sessionContextKey struct{}
|
||||||
|
|
||||||
// Session initializes and adds a session manager to the request context.
|
// 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 := scs.New()
|
||||||
sessionManager.Lifetime = config.DefaultSessionDuration
|
sessionManager.Lifetime = config.DefaultSessionDuration
|
||||||
sessionManager.Cookie = scs.SessionCookie{
|
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)
|
sessionManager, err := ctx.Value(sessionContextKey{}).(*scs.SessionManager)
|
||||||
if !err {
|
if !err {
|
||||||
// This should never happen.
|
// This should never happen.
|
||||||
|
|
|
@ -30,7 +30,7 @@ func NewServer(cfg *config.Config) *Server {
|
||||||
mux.Use(middleware.RealIP)
|
mux.Use(middleware.RealIP)
|
||||||
mux.Use(middleware.Logger)
|
mux.Use(middleware.Logger)
|
||||||
mux.Use(middleware.Recoverer)
|
mux.Use(middleware.Recoverer)
|
||||||
mux.Use(servermiddleware.Session(cfg))
|
mux.Use(servermiddleware.SessionManager(cfg))
|
||||||
mux.Use(middleware.Timeout(config.RequestTimeout))
|
mux.Use(middleware.Timeout(config.RequestTimeout))
|
||||||
|
|
||||||
// Create the server
|
// 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 {
|
if err != nil {
|
||||||
// Even if the user does not exist, hash a password to waste time
|
// Even if the user does not exist, hash a password to waste time
|
||||||
// and not give away wether or not the user exists.
|
// 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)
|
return &models.User{}, fmt.Errorf("failed to find user: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,8 +15,11 @@ type MemoryStorage struct {
|
||||||
storage.Storage
|
storage.Storage
|
||||||
shortMu sync.RWMutex
|
shortMu sync.RWMutex
|
||||||
userMu sync.RWMutex
|
userMu sync.RWMutex
|
||||||
|
tokenMu sync.RWMutex
|
||||||
shortMap map[string]*models.Short
|
shortMap map[string]*models.Short
|
||||||
userMap map[string]*models.User
|
userMap map[string]*models.User
|
||||||
|
tokenMap map[string]*models.Token
|
||||||
|
tokenIDMap map[string]*models.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMemoryStorage creates a new MemoryStorage.
|
// NewMemoryStorage creates a new MemoryStorage.
|
||||||
|
@ -24,6 +27,8 @@ func NewMemoryStorage() *MemoryStorage {
|
||||||
return &MemoryStorage{
|
return &MemoryStorage{
|
||||||
shortMap: make(map[string]*models.Short),
|
shortMap: make(map[string]*models.Short),
|
||||||
userMap: make(map[string]*models.User),
|
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
|
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
|
CreateUser(ctx context.Context, user *models.User) error
|
||||||
// DeleteUser deletes a user and all their shorts from the storage.
|
// DeleteUser deletes a user and all their shorts from the storage.
|
||||||
DeleteUser(ctx context.Context, user *models.User) error
|
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