I'm done
Some checks failed
Build / build (push) Has been cancelled

This commit is contained in:
Gustavo Maronato 2023-08-24 22:03:58 -03:00
parent 16bb373c60
commit 949ea57dd9
Signed by: maronato
SSH Key Fingerprint: SHA256:2Gw7kwMz/As+2UkR1qQ/qYYhn+WNh3FGv6ozhoRrLcs
49 changed files with 1179 additions and 502 deletions

View File

@ -2,6 +2,7 @@
*.db
*.db-shm
*.db-wal
results.bin
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.db
*.db-shm
*.db-wal
results.bin
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
golang 1.21.0

View File

@ -1,5 +1,5 @@
# Load the golang image
FROM golang:1.20.7 as go-builder
FROM golang:1.21.0 as go-builder
# Then the node image
FROM node:20.5.0 as builder
@ -15,7 +15,6 @@ WORKDIR /go/src/app
ENV GOPATH=/go
ENV PATH=$GOPATH/bin:/usr/local/go/bin:$PATH
ENV GOCACHE=/tmp/.go-build-cache
ENV CGO_ENABLED=0
# This variable communicates to the service that it's running inside
# a docker container.
ENV ENV_DOCKER=true
@ -51,10 +50,13 @@ WORKDIR /app
# Set our runtime environment
ENV ENV_DOCKER=true
ENV GOSHORT_PROD=true
COPY --from=builder /go/src/app/goshort /usr/local/bin/goshort
HEALTHCHECK CMD [ "goshort", "healthcheck" ]
EXPOSE 8080
ENTRYPOINT [ "goshort" ]
CMD [ "serve" ]

View File

@ -7,7 +7,7 @@ frontend:
VITE_API_URL=/api npm run --prefix frontend build
backend:
CGO_ENABLED=0 go build -o goshort goshort.go
go build -o goshort goshort.go
all:
make frontend

View File

@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
@ -16,9 +17,11 @@ import (
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/peterbourgon/ff/v3/ffcli"
@ -50,6 +53,9 @@ func New(cfg *config.Config) *ffcli.Command {
}
func exec(ctx context.Context, cfg *config.Config) error {
l := logging.FromCtx(ctx)
l.Debug("Executing dev command", slog.Any("config", cfg))
eg, egCtx := errgroup.WithContext(ctx)
// Start the API server
@ -84,11 +90,16 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
shortService := shortservice.NewShortService(storage)
userService := userservice.NewUserService(cfg, storage)
tokenService := tokenservice.NewTokenService(storage)
shortLogService := shortlogservice.NewShortLogService(storage)
// Start short log worker
stopWorker := shortLogService.StartWorker(ctx)
defer stopWorker()
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
shortHandler := shortserver.NewShortHandler(shortService)
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService)
shortHandler := shortserver.NewShortHandler(shortService, shortLogService)
healthcheckHandler := healthcheckserver.NewHealthcheckHandler(storage)
// Create routers
apiRouter := apiserver.NewAPIRouter(apiHandler)
@ -105,7 +116,7 @@ func serveAPI(ctx context.Context, cfg *config.Config) error {
AllowedOrigins: []string{
"http://" + net.JoinHostPort(cfg.Host, cfg.UIPort),
},
AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"},
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
AllowCredentials: true,
}))

View File

@ -4,12 +4,14 @@ import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"git.maronato.dev/maronato/goshort/cmd/shared"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/peterbourgon/ff/v3/ffcli"
)
@ -38,6 +40,9 @@ func New(cfg *config.Config) *ffcli.Command {
// If the request fails, return an error
// Otherwise, return nil.
func exec(ctx context.Context, cfg *config.Config) error {
l := logging.FromCtx(ctx)
l.Debug("Executing healthcheck command", slog.Any("config", cfg))
addr := url.URL{
Host: net.JoinHostPort(cfg.Host, cfg.Port),
Scheme: "http",

View File

@ -12,6 +12,7 @@ import (
healthcheckcmd "git.maronato.dev/maronato/goshort/cmd/healthcheck"
servecmd "git.maronato.dev/maronato/goshort/cmd/serve"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/peterbourgon/ff/v3/ffcli"
)
@ -29,6 +30,7 @@ func Run() {
}
// Look for the env ENV_DOCKER=true to disable the dev command
// since the docker image won't have node installed.
if os.Getenv("ENV_DOCKER") != "true" {
rootCmd.Subcommands = append(rootCmd.Subcommands, devcmd.New(cfg))
}
@ -45,6 +47,10 @@ func Run() {
os.Exit(1)
}
// Create system logger
l := logging.NewLogger(cfg)
ctx = logging.WithLogger(ctx, l)
// Run the command.
if err := rootCmd.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)

View File

@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"log/slog"
"git.maronato.dev/maronato/goshort/cmd/shared"
"git.maronato.dev/maronato/goshort/internal/config"
@ -14,9 +15,11 @@ import (
shortserver "git.maronato.dev/maronato/goshort/internal/server/short"
staticssterver "git.maronato.dev/maronato/goshort/internal/server/static"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
handlerutils "git.maronato.dev/maronato/goshort/internal/util/handler"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/chi/v5"
"github.com/peterbourgon/ff/v3/ffcli"
)
@ -27,6 +30,7 @@ func New(cfg *config.Config) *ffcli.Command {
shared.RegisterBaseFlags(fs, cfg)
shared.RegisterServerFlags(fs, cfg)
fs.BoolVar(&cfg.Prod, "prod", config.DefaultProd, "run in production mode")
// Create the command and options
cmd := shared.NewCommand(cfg, exec)
@ -44,6 +48,9 @@ func New(cfg *config.Config) *ffcli.Command {
}
func exec(ctx context.Context, cfg *config.Config) error {
l := logging.FromCtx(ctx)
l.Debug("Executing serve command", slog.Any("config", cfg))
// Create the new server
server := server.NewServer(cfg)
@ -58,12 +65,17 @@ func exec(ctx context.Context, cfg *config.Config) error {
shortService := shortservice.NewShortService(storage)
userService := userservice.NewUserService(cfg, storage)
tokenService := tokenservice.NewTokenService(storage)
shortLogService := shortlogservice.NewShortLogService(storage)
// Start short log worker
stopWorker := shortLogService.StartWorker(ctx)
defer stopWorker()
// Create handlers
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService)
shortHandler := shortserver.NewShortHandler(shortService)
apiHandler := apiserver.NewAPIHandler(shortService, userService, tokenService, shortLogService)
shortHandler := shortserver.NewShortHandler(shortService, shortLogService)
staticHandler := staticssterver.NewStaticHandler(cfg)
healthcheckHandler := healthcheckserver.NewHealthcheckHandler()
healthcheckHandler := healthcheckserver.NewHealthcheckHandler(storage)
// Create routers
apiRouter := apiserver.NewAPIRouter(apiHandler)

View File

@ -41,6 +41,7 @@ func RegisterBaseFlags(fs *flag.FlagSet, cfg *config.Config) {
fs.BoolVar(&cfg.Debug, "debug", config.DefaultDebug, "enable debug mode")
var defaultHost = config.DefaultHost
if os.Getenv("ENV_DOCKER") == "true" {
// This is a QOL hack to allow docker to bind the port without manually specifying the host
defaultHost = "0.0.0.0"
}
fs.StringVar(&cfg.Host, "host", defaultHost, "host to listen on")
@ -59,7 +60,6 @@ func RegisterServerFlags(fs *flag.FlagSet, cfg *config.Config) {
func InitStorage(cfg *config.Config) storage.Storage {
switch cfg.DBType {
case config.DBTypeMemory:
cfg.DBURL = ":memory:"
return sqlitestorage.NewSQLiteStorage(cfg)
case config.DBTypeSQLite:
return sqlitestorage.NewSQLiteStorage(cfg)

View File

@ -14,12 +14,13 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-use": "^17.4.0"
"ua-parser-js": "^1.0.35"
},
"devDependencies": {
"@marolint/eslint-config-react": "^1.0.2",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/ua-parser-js": "^0.7.36",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.47.0",
@ -55,6 +56,7 @@
"version": "7.22.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz",
"integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -856,11 +858,6 @@
"node": ">=10"
}
},
"node_modules/@types/js-cookie": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA=="
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
@ -911,6 +908,12 @@
"integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==",
"dev": true
},
"node_modules/@types/ua-parser-js": {
"version": "0.7.36",
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz",
"integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
@ -1133,11 +1136,6 @@
"vite": "^4"
}
},
"node_modules/@xobotyi/scrollbar-width": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="
},
"node_modules/acorn": {
"version": "8.10.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
@ -1668,14 +1666,6 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -1690,26 +1680,6 @@
"node": ">= 8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1725,7 +1695,8 @@
"node_modules/csstype": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==",
"dev": true
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@ -1829,14 +1800,6 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/es-abstract": {
"version": "1.22.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz",
@ -2434,7 +2397,8 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"node_modules/fast-diff": {
"version": "1.3.0",
@ -2482,21 +2446,6 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
"node_modules/fast-loops": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.3.tgz",
"integrity": "sha512-8EZzEP0eKkEEVX+drtd9mtuQ+/QrlfW/5MlwcwK5Nds6EkZ/tRzEexkzUY2mIssnAyVLT+TKHuRXmFNNXYUd6g=="
},
"node_modules/fast-shallow-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fastest-stable-stringify": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q=="
},
"node_modules/fastq": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
@ -2852,11 +2801,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
"integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
},
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@ -2907,15 +2851,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
},
"node_modules/inline-style-prefixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.4.tgz",
"integrity": "sha512-FwXmZC2zbeeS7NzGjJ6pAiqRhXR0ugUShSNb6GApMl6da0/XGc4MOJsoWAywia52EEWbXNSy0pzkwz/+Y+swSg==",
"dependencies": {
"css-in-js-utils": "^3.1.0",
"fast-loops": "^1.1.3"
}
},
"node_modules/internal-slot": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz",
@ -3290,11 +3225,6 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -3438,11 +3368,6 @@
"node": ">=10"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3503,25 +3428,6 @@
"thenify-all": "^1.0.0"
}
},
"node_modules/nano-css": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.3.5.tgz",
"integrity": "sha512-vSB9X12bbNu4ALBu7nigJgRViZ6ja3OU7CeuiV1zMIbXOdmkLahgtPmh3GBOlDxbKY0CitqlPdOReGlBLSp+yg==",
"dependencies": {
"css-tree": "^1.1.2",
"csstype": "^3.0.6",
"fastest-stable-stringify": "^2.0.2",
"inline-style-prefixer": "^6.0.0",
"rtl-css-js": "^1.14.0",
"sourcemap-codec": "^1.4.8",
"stacktrace-js": "^2.0.2",
"stylis": "^4.0.6"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
@ -4115,45 +4021,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-universal-interface": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
"integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
"peerDependencies": {
"react": "*",
"tslib": "*"
}
},
"node_modules/react-use": {
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/react-use/-/react-use-17.4.0.tgz",
"integrity": "sha512-TgbNTCA33Wl7xzIJegn1HndB4qTS9u03QUwyNycUnXaweZkE4Kq2SB+Yoxx8qbshkZGYBDvUXbXWRUmQDcZZ/Q==",
"dependencies": {
"@types/js-cookie": "^2.2.6",
"@xobotyi/scrollbar-width": "^1.9.5",
"copy-to-clipboard": "^3.3.1",
"fast-deep-equal": "^3.1.3",
"fast-shallow-equal": "^1.0.0",
"js-cookie": "^2.2.1",
"nano-css": "^5.3.1",
"react-universal-interface": "^0.6.2",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.1.0",
"set-harmonic-interval": "^1.0.1",
"throttle-debounce": "^3.0.1",
"ts-easing": "^0.2.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-use/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@ -4198,7 +4065,8 @@
"node_modules/regenerator-runtime": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
"integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==",
"dev": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.0",
@ -4217,11 +4085,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
},
"node_modules/resolve": {
"version": "1.22.4",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz",
@ -4289,14 +4152,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -4360,17 +4215,6 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/screenfull": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
@ -4386,14 +4230,6 @@
"node": ">=10"
}
},
"node_modules/set-harmonic-interval": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
"integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==",
"engines": {
"node": ">=6.9"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4438,14 +4274,6 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@ -4455,52 +4283,6 @@
"node": ">=0.10.0"
}
},
"node_modules/sourcemap-codec": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead"
},
"node_modules/stack-generator": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz",
"integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="
},
"node_modules/stacktrace-gps": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz",
"integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==",
"dependencies": {
"source-map": "0.5.6",
"stackframe": "^1.3.4"
}
},
"node_modules/stacktrace-gps/node_modules/source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stacktrace-js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
"dependencies": {
"error-stack-parser": "^2.0.6",
"stack-generator": "^2.0.5",
"stacktrace-gps": "^3.0.4"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz",
@ -4598,11 +4380,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stylis": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz",
"integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ=="
},
"node_modules/sucrase": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
@ -4733,14 +4510,6 @@
"node": ">=0.8"
}
},
"node_modules/throttle-debounce": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
"integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==",
"engines": {
"node": ">=10"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4753,16 +4522,6 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="
},
"node_modules/ts-easing": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
"integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ=="
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -4784,7 +4543,8 @@
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true
},
"node_modules/tsutils": {
"version": "3.21.0",
@ -4903,6 +4663,24 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.35",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz",
"integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
}
],
"engines": {
"node": "*"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@ -16,12 +16,13 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"react-use": "^17.4.0"
"ua-parser-js": "^1.0.35"
},
"devDependencies": {
"@marolint/eslint-config-react": "^1.0.2",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/ua-parser-js": "^0.7.36",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"eslint": "^8.47.0",

View File

@ -0,0 +1,153 @@
ancient
antique
aquatic
baby
basic
big
bitter
black
blue
bottle
bottled
brave
breezy
bright
brown
calm
charming
cheerful
chummy
classy
clear
clever
cloudy
cold
cool
crispy
curly
daily
deep
delightful
dizzy
down
dynamic
elated
elegant
excited
exotic
fancy
fast
fearless
festive
fluffy
fragile
fresh
friendly
funny
fuzzy
gentle
gifted
gigantic
graceful
grand
grateful
great
green
happy
heavy
helpful
hot
hungry
husky
icy
imaginary
invisible
jagged
jolly
joyful
joyous
kind
large
light
little
lively
lovely
lucky
lumpy
magical
manic
melodic
mighty
misty
modern
narrow
new
nifty
noisy
normal
odd
old
orange
ordinary
painless
pastel
peaceful
perfect
phobic
pink
polite
precious
pretty
purple
quaint
quick
quiet
rapid
red
rocky
rough
round
royal
rugged
rustic
safe
sandy
shiny
silent
silky
silly
slender
slow
small
smiling
smooth
snug
soft
sour
strange
strong
sunny
sweet
swift
thirsty
thoughtful
tiny
uneven
unusual
vanilla
vast
violet
warm
watery
weak
white
wide
wild
wilde
windy
wise
witty
wonderful
yellow
young
zany

136
frontend/public/nouns.txt Normal file
View File

@ -0,0 +1,136 @@
airplane
apple
automobile
ball
balloon
banana
beach
bird
boat
boot
bottle
box
breeze
bug
bush
butter
canoe
carrot
cartoon
cello
chair
cheese
coast
coconut
comet
cream
curtain
daisy
desk
diamond
door
earth
elephant
emerald
fire
flamingo
flower
flute
forest
free
giant
giraffe
glove
grape
grasshopper
hair
hat
hill
house
ink
iris
jade
jungle
kangaroo
kayak
lake
lemon
lightning
lion
lotus
lump
mango
mint
monkey
moon
motorcycle
mountain
nest
oboe
ocean
octopus
onion
orange
orchestra
owl
path
penguin
phoenix
piano
pineapple
planet
pond
potato
prairie
quail
rabbit
raccoon
raid
rain
raven
river
road
rosebud
ruby
sea
ship
shoe
shore
shrub
sitter
skates
sky
socks
sparrow
spider
squash
squirrel
star
stream
street
sun
table
teapot
terrain
tiger
toast
tomato
trail
train
tree
truck
trumpet
tuba
tulip
umbrella
unicorn
unit
valley
vase
violet
violin
water
wind
window
zebra
zoo

View File

@ -10,7 +10,6 @@ const ItemList = <T extends Record<string, unknown>, K extends keyof T>({
idKey: K
Item: FunctionComponent<T>
}) => {
console.log("list")
return (
<>
<ul

View File

@ -127,7 +127,7 @@ export default function Navbar() {
isActive
? "bg-blue-100 text-blue-600 border-blue-500"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-600 hover:border-slate-500",
"block border-l-4 border-transparent px-3 py-2 text-base font-medium"
"block border-l-4 px-3 py-2 text-base font-medium"
)
}>
{item.name}

View File

@ -1,11 +0,0 @@
import { FunctionComponent } from "react"
const NoItems: FunctionComponent<{ type: string }> = ({ type }) => {
return (
<div className="text-center pt-5 text-xl font-light">
{`No ${type}s yet`}
</div>
)
}
export default NoItems

View File

@ -1,12 +1,36 @@
import { FunctionComponent, useCallback } from "react"
import { FunctionComponent, useCallback, useMemo } from "react"
import {
ComputerDesktopIcon,
DevicePhoneMobileIcon,
DeviceTabletIcon,
TvIcon,
} from "@heroicons/react/24/outline"
import { useNavigate } from "react-router-dom"
import { UAParser } from "ua-parser-js"
import { useDelete } from "../hooks/useCRUD"
import { Session } from "../types"
import { formatDateStr } from "../util/date"
import ItemBase from "./ItemBase"
const UAIcon: FunctionComponent<{ device?: string; className?: string }> = ({
device,
className,
}) => {
switch (device) {
case "mobile":
return <DevicePhoneMobileIcon className={className} />
case "tablet":
return <DeviceTabletIcon className={className} />
case "smarttv":
return <TvIcon className={className} />
default:
return <ComputerDesktopIcon className={className} />
}
}
const SessionItem: FunctionComponent<Session> = ({ ...session }) => {
// Handle deletion
const [deleting, deleteOther] = useDelete()
@ -21,9 +45,54 @@ const SessionItem: FunctionComponent<Session> = ({ ...session }) => {
}
}, [deleteCurrent, deleteOther, session])
const ua = useMemo(() => {
const ua = new UAParser(session.userAgent)
return {
device: ua.getDevice().type,
browser: ua.getBrowser().name,
os: ua.getOS().name,
osVersion: ua.getOS().version,
}
}, [session.userAgent])
const lastActivity = formatDateStr(session.lastActivity)
return (
<ItemBase doDelete={doDelete} deleting={deleting}>
{JSON.stringify(session)}
<div className="grid grid-cols-12 gap-y-4 mb-2">
<div className="col-span-8 order-1 sm:col-span-4 flex flex-row items-start">
<div>
<UAIcon device={ua.device} className="w-10 h-10 text-blue-500" />
</div>
<div className="flex flex-col">
<span className="ml-2 text-lg font-semibold text-slate-800">
{ua.browser}
</span>
<span className="ml-2 text-sm text-gray-500">
{session.ipAddress}
</span>
</div>
</div>
<div className="col-span-12 order-3 sm:col-span-6 sm:order-2">
<div className="flex flex-col">
<span className="ml-2 text-sm font-medium text-gray-500">
{ua.os} {ua.osVersion}
</span>
<span className="ml-2 text-sm font-light text-gray-500">
Last access: <span className="font-medium">{lastActivity}</span>
</span>
</div>
</div>
<div className="col-span-4 order-2 sm:col-span-2 sm:order-3">
<div className="flex flex-row justify-end">
{session.current ? (
<span className="px-2 py-1 rounded-full bg-green-200 text-sm font-bold text-green-600">
Current
</span>
) : null}
</div>
</div>
</div>
</ItemBase>
)
}

View File

@ -1,17 +1,144 @@
import { FunctionComponent, useCallback } from "react"
import {
EventHandler,
FunctionComponent,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDelete } from "../hooks/useCRUD"
import { CheckCircleIcon, PencilSquareIcon } from "@heroicons/react/20/solid"
import { EyeIcon, EyeSlashIcon } from "@heroicons/react/24/outline"
import { Form } from "react-router-dom"
import { useDelete, usePatch } from "../hooks/useCRUD"
import { Token } from "../types"
import { formatDateStr } from "../util/date"
import ItemBase from "./ItemBase"
const TokenNameField: FunctionComponent<{ name: string; id: string }> = ({
name,
id,
}) => {
const [editing, setEditing] = useState(false)
const [newName, setNewName] = useState(name)
// Reset name when it changes
useEffect(() => {
setNewName(name)
setEditing(false)
}, [name])
// Handle submission
const [loading, patch] = usePatch()
const handleSubmit: EventHandler<SyntheticEvent> = useCallback(
(e) => {
e.preventDefault()
e.stopPropagation()
patch({ id: id, name: newName })
},
[newName, id, patch]
)
// This prevents blur from messing up submission when clicking the
// submit button directly
const stopBlur: EventHandler<SyntheticEvent> = useCallback((e) => {
e.preventDefault()
}, [])
// Enable/disable editing when the edit button is clicked
// and always reset the edit text to the current text
const handleEdit = useCallback(() => {
if (loading) return
setEditing((e) => !e)
setNewName(name)
}, [name, loading])
return (
<div
className="flex flex-row text-xl font-mono text-slate-600"
onBlur={handleEdit}>
{editing ? (
<Form onSubmit={handleSubmit} className="flex flex-row">
<input
style={{ width: `${newName.length + 1}ch` }}
className="border-b border-blue-500"
type="text"
value={newName}
disabled={loading}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<button
disabled={loading}
type="submit"
onMouseDown={stopBlur}
className="p-1 ml-1 text-green-700 rounded-full z-30 disabled:text-slate-600">
<CheckCircleIcon className="h-6 w-6" />
</button>
</Form>
) : (
<span className="flex flex-row">
<div className="flex flex-row items-center text-xl">
<span>{name}</span>
</div>
<button onClick={handleEdit} className="p-1 ml-4">
<PencilSquareIcon className="h-6 w-6" />
</button>
</span>
)}
</div>
)
}
const TokenValue: FunctionComponent<{ value: string }> = ({ value }) => {
const [show, setShow] = useState(false)
const breakStyle = useMemo(() => {
return show ? {} : { wordBreak: "break-word" as const }
}, [show])
return (
<div className="text-sm font-mono flex flex-row items-center break-all">
<span
className="bg-slate-600 text-slate-100 px-2 py-1 rounded-md"
style={breakStyle}>
{show ? value : "*".repeat(value.length)}
</span>
<button
onClick={() => setShow((s) => !s)}
className="ml-2 p-1 rounded-full text-slate-600">
{show ? (
<EyeSlashIcon className="h-6 w-6" />
) : (
<EyeIcon className="h-6 w-6" />
)}
</button>
</div>
)
}
const TokenItem: FunctionComponent<Token> = ({ ...token }) => {
const [deleting, del] = useDelete()
const doDelete = useCallback(() => del({ id: token.id }), [del, token.id])
const createdAt = formatDateStr(token.createdAt)
return (
<ItemBase copyString={token.value} doDelete={doDelete} deleting={deleting}>
{JSON.stringify(token)}
<div className="grid grid-cols-12 gap-y-2 mb-2">
<div className="col-span-12">
<TokenNameField name={token.name} id={token.id} />
</div>
<div className="col-span-12 text-slate-500 font-light text-sm">
Created at: <span className="font-medium">{createdAt}</span>
</div>
<div className="col-span-12 w-fit">
<TokenValue value={token.value} />
</div>
</div>
</ItemBase>
)
}

View File

@ -1,6 +1,6 @@
import { SetStateAction, useCallback, useEffect, useState } from "react"
import { useNavigation, useSubmit } from "react-router-dom"
import { FormMethod, useNavigation, useSubmit } from "react-router-dom"
import { SubmitTarget } from "react-router-dom/dist/dom"
import fetchAPI from "../util/fetchAPI"
@ -73,58 +73,34 @@ export const useOnCreate = <T extends Record<string, unknown>>(
return [creating, create] as const
}
export const useCreate = <T extends SubmitTarget>(
onCreate?: (payload?: T) => void
) => {
const submit = useSubmit()
const navigation = useNavigation()
const [creating, setCreating] = useState(false)
const makeUseVerb =
(verb: Uppercase<FormMethod>) =>
<T extends SubmitTarget>(onSubmit?: (payload?: T) => void) => {
const submit = useSubmit()
const navigation = useNavigation()
const [submitting, setSubmitting] = useState(false)
const create = useCallback(
(payload?: T) => {
setCreating(true)
submit(payload ?? null, {
method: "POST",
replace: true,
})
if (onCreate) onCreate(payload)
},
[submit, onCreate]
)
const doSubmit = useCallback(
(payload?: T) => {
setSubmitting(true)
submit(payload ?? null, {
method: verb,
replace: true,
})
if (onSubmit) onSubmit(payload)
},
[submit, onSubmit]
)
useEffect(() => {
if (navigation.state === "idle") {
setCreating(false)
}
}, [navigation])
useEffect(() => {
if (navigation.state === "idle") {
setSubmitting(false)
}
}, [navigation])
return [creating, create] as const
}
return [submitting, doSubmit] as const
}
export const useDelete = <T extends SubmitTarget>(
onDelete?: (payload: T) => void
) => {
const submit = useSubmit()
const navigation = useNavigation()
const [deleting, setDeleting] = useState(false)
const del = useCallback(
(payload: T) => {
setDeleting(true)
submit(payload ?? null, {
method: "DELETE",
replace: true,
})
if (onDelete) onDelete(payload)
},
[submit, onDelete]
)
useEffect(() => {
if (navigation.state === "idle") {
setDeleting(false)
}
}, [navigation])
return [deleting, del] as const
}
export const useCreate = makeUseVerb("POST")
export const useDelete = makeUseVerb("DELETE")
export const usePatch = makeUseVerb("PATCH")

View File

@ -150,7 +150,7 @@ export const useVisitMetrics = (logs: ShortLog[]) => {
let last = logs[0].createdAt
logs.forEach((log) => {
// If the log is older, update
if (log.createdAt.localeCompare(last) < 0) {
if (log.createdAt.localeCompare(last) > 0) {
last = log.createdAt
}
// Add IP to set

View File

@ -187,7 +187,7 @@ export const Component: FunctionComponent = () => {
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">
<div className="bg-white 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>
@ -212,9 +212,11 @@ export const Component: FunctionComponent = () => {
{shortening ? "Shortening..." : "Shorten it"}
</Button>
</form>
<div className="mt-10">
<ItemList items={recentShorts} Item={ShortItem} idKey="id" />
</div>
{recentShorts.length ? (
<div className="mt-10">
<ItemList items={recentShorts} Item={ShortItem} idKey="id" />
</div>
) : null}
</>
)
}

View File

@ -55,7 +55,7 @@ export function Component() {
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"
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<Button

View File

@ -1,10 +1,9 @@
import { FunctionComponent, useCallback } from "react"
import { useCallback } from "react"
import { LoaderFunction, redirect } from "react-router-dom"
import Header from "../components/Header"
import ItemList from "../components/ItemList"
import NoItems from "../components/NoItems"
import SessionItem from "../components/SessionItem"
import { protectedLoader } from "../hooks/useAuth"
import { useSortedLoadedItems } from "../hooks/useLoaderItems"
@ -17,14 +16,10 @@ export function Component() {
useCallback((a, b) => b.lastActivity.localeCompare(a.lastActivity), [])
)
const Sessions: FunctionComponent = () => {
return <ItemList items={items} Item={SessionItem} idKey="id" />
}
return (
<>
<Header title="Sessions" />
{items.length > 0 ? <Sessions /> : <NoItems type="Session" />}
<ItemList items={items} Item={SessionItem} idKey="id" />
</>
)
}

View File

@ -11,7 +11,7 @@ export function Component() {
return (
<>
<BackButton className="absolute mt-10" />
<BackButton className="absolute mt-5" />
<Header title={short.name} />
{short.url}
</>

View File

@ -55,7 +55,7 @@ export function Component() {
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"
className="p-2 border-b-2 bg-slate-50 border-slate-400 text-lg focus:outline-none focus:border-blue-500 transition-colors duration-200"
/>
</label>
<Button

View File

@ -1,4 +1,4 @@
import { FunctionComponent, useCallback } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PlusIcon } from "@heroicons/react/24/outline"
import { LoaderFunction, redirect } from "react-router-dom"
@ -18,19 +18,36 @@ export function Component() {
const [items] = useSortedLoadedItems<Token>(
useCallback((a, b) => b.createdAt.localeCompare(a.createdAt), [])
)
const [nounlist, setNounList] = useState<string[]>(useMemo(() => [], []))
const [adjlist, setAdjlist] = useState<string[]>(useMemo(() => [], []))
useEffect(() => {
const fetchWords = async () => {
const nounsReq = await fetch("/nouns.txt")
const adjsReq = await fetch("/adjectives.txt")
if (nounsReq.ok && adjsReq.ok) {
const nouns = (await nounsReq.text()).split("\n")
const adjs = await (await adjsReq.text()).split("\n")
setNounList(nouns)
setAdjlist(adjs)
}
}
fetchWords()
}, [])
const generateName = useCallback(() => {
if (adjlist.length === 0 || nounlist.length === 0) return null
const adj = adjlist[Math.floor(Math.random() * adjlist.length)]
const noun = nounlist[Math.floor(Math.random() * nounlist.length)]
return `${adj} ${noun}`
}, [adjlist, nounlist])
// Handle creation
const [creating, create] = useCreate()
const onClickCreate = useCallback(() => create(), [create])
const Tokens: FunctionComponent = () => {
return <ItemList items={items} Item={TokenItem} idKey="id" />
}
const NoTokens = () => {
return (
<div className="text-center pt-5 text-xl font-light">No tokens yet</div>
)
}
const onClickCreate = useCallback(
() => create({ name: generateName() }),
[create, generateName]
)
return (
<>
@ -42,7 +59,7 @@ export function Component() {
<PlusIcon className="inline-block w-5 h-5 mb-0.5 mr-1 leading-1" />
<span>New token</span>
</Button>
{items.length > 0 ? <Tokens /> : <NoTokens />}
<ItemList items={items} Item={TokenItem} idKey="id" />
</>
)
}
@ -59,7 +76,14 @@ export const loader: LoaderFunction = async (args) => {
}
export const action = crudAction({
POST: () => fetchAPI<Token>("/tokens", { method: "POST" }),
POST: async (formData) => {
const name = formData.get("name") as string | null
return fetchAPI<Token>("/tokens", {
method: "POST",
body: JSON.stringify({ name }),
})
},
PATCH: async (formData) => {
const id = formData.get("id") as string | null
const name = formData.get("name") as string | null

View File

@ -0,0 +1,9 @@
export const formatDateStr = (dateStr: string): string => {
return new Date(Date.parse(dateStr)).toLocaleString("en", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})
}

16
go.mod
View File

@ -1,6 +1,6 @@
module git.maronato.dev/maronato/goshort
go 1.20
go 1.21
require (
github.com/alexedwards/scs/v2 v2.5.1
@ -10,10 +10,10 @@ require (
github.com/peterbourgon/ff/v3 v3.4.0
github.com/uptrace/bun v1.1.14
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
github.com/uptrace/bun/driver/sqliteshim v1.1.14
github.com/uptrace/bun/extra/bundebug v1.1.14
golang.org/x/crypto v0.12.0
golang.org/x/sync v0.3.0
modernc.org/sqlite v1.25.0
)
require (
@ -25,22 +25,22 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/mod v0.3.0 // indirect
golang.org/x/sys v0.11.0 // indirect
golang.org/x/tools v0.9.1 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.6.0 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.25.0 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.1.0 // indirect
modernc.org/token v1.0.1 // indirect
)

51
go.sum
View File

@ -4,6 +4,7 @@ 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
@ -15,7 +16,9 @@ github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vz
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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@ -27,8 +30,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc=
github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -39,43 +42,65 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
github.com/uptrace/bun/driver/sqliteshim v1.1.14 h1:DFPUJ6KjDP2myjq15gtYYNngmAFMww1Y2UFZv4tbUw8=
github.com/uptrace/bun/driver/sqliteshim v1.1.14/go.mod h1:5BFN7V6Sm37Tn7UE4FWNm/F6V3iJPUzAJ7QyRwA5b1k=
github.com/uptrace/bun/extra/bundebug v1.1.14 h1:9OCGfP9ZDlh41u6OLerWdhBtJAVGXHr0xtxO4xWi6t0=
github.com/uptrace/bun/extra/bundebug v1.1.14/go.mod h1:lto3guzS2v6mnQp1+akyE+ecBLOltevDDe324NXEYdw=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
@ -89,6 +114,8 @@ modernc.org/sqlite v1.25.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=

View File

@ -18,12 +18,13 @@ const (
var DBTypes = [...]string{
DBTypeMemory,
DBTypeSQLite,
DBTypePostgres,
}
const (
// DefaultProd is the default mode.
DefaultProd = false
// DefaultDBType is the default type of database to use.
DefaultDBType = DBTypeMemory
DefaultDBType = DBTypeSQLite
// DefaultDBURL is the default connection string for the database.
DefaultDBURL = "goshort.db"
// DefaultPort is the default port to listen on.

View File

@ -3,6 +3,8 @@ package apiserver
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
@ -10,25 +12,29 @@ import (
"git.maronato.dev/maronato/goshort/internal/server"
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
tokenservice "git.maronato.dev/maronato/goshort/internal/service/token"
userservice "git.maronato.dev/maronato/goshort/internal/service/user"
"git.maronato.dev/maronato/goshort/internal/storage/models"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type APIHandler struct {
shorts *shortservice.ShortService
users *userservice.UserService
tokens *tokenservice.TokenService
shorts *shortservice.ShortService
users *userservice.UserService
tokens *tokenservice.TokenService
shortLogs *shortlogservice.ShortLogService
}
func NewAPIHandler(shorts *shortservice.ShortService, users *userservice.UserService, tokens *tokenservice.TokenService) *APIHandler {
func NewAPIHandler(shorts *shortservice.ShortService, users *userservice.UserService, tokens *tokenservice.TokenService, shortLogs *shortlogservice.ShortLogService) *APIHandler {
return &APIHandler{
shorts: shorts,
users: users,
tokens: tokens,
shorts: shorts,
users: users,
tokens: tokens,
shortLogs: shortLogs,
}
}
@ -74,6 +80,7 @@ func (h *APIHandler) DeleteMe(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logging.FromCtx(ctx)
type loginForm struct {
Username string `json:"username"`
@ -93,6 +100,8 @@ func (h *APIHandler) Login(w http.ResponseWriter, r *http.Request) {
if err != nil {
if errors.Is(err, errs.ErrUserDoesNotExist) || errors.Is(err, errs.ErrFailedAuthentication) {
// If the username or password are wrong, return invalid username/password
l.Debug("failed to authenticate user", "err", err)
server.RenderUnauthorized(w, r)
} else if errors.Is(err, errs.ErrInvalidUser) {
// If the request was invalid, return bad request
@ -126,6 +135,7 @@ func (h *APIHandler) Logout(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logging.FromCtx(ctx)
// Get the user from the json body
type signupForm struct {
@ -162,6 +172,8 @@ func (h *APIHandler) Signup(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, errs.ErrUserExists) || errors.Is(err, errs.ErrInvalidUser) {
server.RenderBadRequest(w, r, err)
} else if errors.Is(err, errs.ErrRegistrationDisabled) {
l.Debug("failed to create user", "err", err)
server.RenderForbidden(w, r)
} else {
server.RenderServerError(w, r, err)
@ -293,7 +305,7 @@ func (h *APIHandler) ListShortLogs(w http.ResponseWriter, r *http.Request) {
}
// Get logs
logs, err := h.shorts.ListLogs(ctx, short)
logs, err := h.shortLogs.ListLogs(ctx, short)
if err != nil {
server.RenderServerError(w, r, err)
@ -326,6 +338,7 @@ func (h *APIHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logging.FromCtx(ctx)
// Get user from context
user, ok := h.findUserOrRespond(w, r)
@ -340,6 +353,8 @@ func (h *APIHandler) DeleteSession(w http.ResponseWriter, r *http.Request) {
err := servermiddleware.DeleteUserSession(ctx, user, sessionToken)
if err != nil {
if errors.Is(err, errs.ErrSessionDoesNotExist) {
l.Debug("could not delete session", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
@ -377,13 +392,25 @@ func (h *APIHandler) ListTokens(w http.ResponseWriter, r *http.Request) {
func (h *APIHandler) CreateToken(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
type tokenNameForm struct {
Name string `json:"name"`
}
form := &tokenNameForm{}
// if error is EOF, it means the body is empty, so we can ignore it
if err := render.DecodeJSON(r.Body, form); err != nil && err != io.EOF {
server.RenderBadRequest(w, r, err)
return
}
// Get user from context
user, ok := h.findUserOrRespond(w, r)
if !ok {
return
}
token, err := h.tokens.CreateToken(ctx, user)
token, err := h.tokens.CreateToken(ctx, user, form.Name)
if err != nil {
server.RenderServerError(w, r, err)
@ -472,6 +499,7 @@ func (h *APIHandler) findUserOrRespond(w http.ResponseWriter, r *http.Request) (
// 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()
l := logging.FromCtx(ctx)
// Get short id from request
id := chi.URLParam(r, "id")
@ -481,6 +509,8 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
if err != nil {
// If the short doesn't exist or is invalid, return not found
if errors.Is(err, errs.ErrShortDoesNotExist) || errors.Is(err, errs.ErrInvalidShort) {
l.Debug("could not find short", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
@ -498,6 +528,8 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
// If the session user does not match the short's user,
// return forbidden.
if user.ID != *short.UserID {
l.Debug("short's user does not match request user", slog.String("short_user", *short.UserID))
server.RenderForbidden(w, r)
return nil, false
@ -511,6 +543,7 @@ func (h *APIHandler) findShortOrRespond(w http.ResponseWriter, r *http.Request)
// the token and true. If it isn't, it returns nil and false.
func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request) (token *models.Token, ok bool) {
ctx := r.Context()
l := logging.FromCtx(ctx)
// Get token ID from request
id := chi.URLParam(r, "id")
@ -520,6 +553,8 @@ func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request)
if err != nil {
// If the token doesn't exist or is invalid, return not found
if errors.Is(err, errs.ErrTokenDoesNotExist) || errors.Is(err, errs.ErrInvalidToken) {
l.Debug("could not find token", "err", err)
server.RenderNotFound(w, r)
} else {
server.RenderServerError(w, r, err)
@ -537,6 +572,8 @@ func (h *APIHandler) findTokenOrRespond(w http.ResponseWriter, r *http.Request)
// If the session user does not match the token's user,
// return NotFound.
if user.ID != *token.UserID {
l.Debug("token's user does not match request user", slog.String("token_user", *token.UserID))
server.RenderNotFound(w, r)
return nil, false

View File

@ -3,6 +3,7 @@ package devuiserver
import (
"context"
"fmt"
"log/slog"
"net"
"net/url"
"os"
@ -10,6 +11,7 @@ import (
"syscall"
"git.maronato.dev/maronato/goshort/internal/config"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"golang.org/x/sync/errgroup"
)
@ -41,8 +43,11 @@ func NewServer(cfg *config.Config) *Server {
}
func (s *Server) ListenAndServe(ctx context.Context) error {
l := logging.FromCtx(ctx)
eg, egCtx := errgroup.WithContext(ctx)
eg.Go(func() error {
l.Info("Starting UI dev server", slog.String("addr", net.JoinHostPort(s.host, s.uiPort)))
return s.Start(egCtx)
})
@ -50,6 +55,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Wait for the context to be done
<-egCtx.Done()
// Shutdown the server
l.Info("Shutting down UI dev server")
return s.Shutdown()
})
@ -61,6 +68,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
}
func (s *Server) Start(ctx context.Context) error {
l := logging.FromCtx(ctx)
// Create a new context with a cancel function so we can stop the server
uiCtx, cancel := context.WithCancel(ctx)
defer cancel()
@ -109,6 +118,8 @@ func (s *Server) Start(ctx context.Context) error {
return fmt.Errorf("error killing process group: %w", err)
}
l.Info("UI dev server shutdown complete")
return nil
})

View File

@ -43,10 +43,6 @@ func ErrBadRequest(err error) render.Renderer {
return ErrGeneric(err, http.StatusBadRequest)
}
func ErrServerError(err error) render.Renderer {
return ErrGeneric(err, http.StatusInternalServerError)
}
func ErrNotFound() render.Renderer {
return ErrBasic(http.StatusNotFound)
}
@ -84,7 +80,8 @@ func RenderBadRequest(w http.ResponseWriter, r *http.Request, err error) {
}
func RenderServerError(w http.ResponseWriter, r *http.Request, err error) {
RenderRender(w, r, ErrServerError(err))
// Panic so the stack trace is printed
panic(err)
}
func RenderNotFound(w http.ResponseWriter, r *http.Request) {

View File

@ -3,32 +3,74 @@ package healthcheckserver
import (
"net/http"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/render"
)
type HealthcheckHandler struct{}
type HealthCheckStatus string
func NewHealthcheckHandler() *HealthcheckHandler {
const (
HealthCheckStatusOk HealthCheckStatus = "ok"
HealthCheckStatusDegraded HealthCheckStatus = "degraded"
HealthCheckStatusDown HealthCheckStatus = "down"
)
return &HealthcheckHandler{}
type HealthcheckHandler struct {
storage storage.Storage
}
func NewHealthcheckHandler(storage storage.Storage) *HealthcheckHandler {
return &HealthcheckHandler{
storage: storage,
}
}
func (h *HealthcheckHandler) CheckHealth(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logging.FromCtx(ctx)
// Defaults
databaseOk := true
serverOk := true
// Check if storage is ok
if err := h.storage.Ping(ctx); err != nil {
l.Error("error pinging database", err)
databaseOk = false
}
// All checks must pass
overallStatus := HealthCheckStatusOk
statusCode := http.StatusOK
if !databaseOk || !serverOk {
overallStatus = HealthCheckStatusDegraded
statusCode = http.StatusInternalServerError
} else if !databaseOk && !serverOk {
// Down is never going to be the case, since to respond
// the server has to be running. This is just for the sake
// of completeness.
overallStatus = HealthCheckStatusDown
statusCode = http.StatusServiceUnavailable
}
// Create the response
response := &healthCheckResponse{
Status: "ok",
ServerOK: true,
DatabaseOK: true,
Status: overallStatus,
ServerOK: serverOk,
DatabaseOK: databaseOk,
}
// Render the response
render.Status(r, http.StatusOK)
render.Status(r, statusCode)
render.JSON(w, r, response)
}
type healthCheckResponse struct {
// Status is the status of the health check
Status string `json:"status"`
Status HealthCheckStatus `json:"status"`
// ServerOK is the status of the server
ServerOK bool `json:"server_ok"`
// DatabaseOK is the status of the database

View File

@ -0,0 +1,74 @@
package servermiddleware
import (
"log/slog"
"net/http"
"time"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/chi/v5/middleware"
)
type RequestLogFormatter struct{}
func NewLogFormatter() *RequestLogFormatter {
return &RequestLogFormatter{}
}
// RequestLogEntry is a struct that implements the LogEntry interface
type RequestLogEntry struct {
l *slog.Logger
}
func (rl *RequestLogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
ctx := r.Context()
l := logging.FromCtx(ctx)
reqID := middleware.GetReqID(ctx)
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
requestGroup := slog.Group("request",
slog.String("id", reqID),
slog.String("scheme", scheme),
slog.String("host", r.Host),
slog.String("method", r.Method),
slog.String("remote_addr", r.RemoteAddr),
slog.String("path", r.URL.Path),
slog.String("user_agent", r.UserAgent()),
slog.String("referer", r.Referer()),
slog.String("content_type", r.Header.Get("Content-Type")),
slog.Int64("content_length", r.ContentLength),
)
l = l.With(requestGroup)
return &RequestLogEntry{
l: l,
}
}
func (le *RequestLogEntry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
responseGroup := slog.Group("response",
slog.Int("status", status),
slog.Duration("duration", elapsed),
slog.Int("content_length", bytes),
slog.String("content_type", header.Get("Content-Type")),
)
l := le.l.With(responseGroup)
switch {
case status >= http.StatusInternalServerError:
l.Error("Server error")
case status >= http.StatusBadRequest:
l.Info("Client error")
default:
l.Info("Request completed")
}
}
func (le *RequestLogEntry) Panic(v interface{}, stack []byte) {
middleware.PrintPrettyStack(v)
}

View File

@ -20,7 +20,7 @@ func SessionManager(cfg *config.Config) func(http.Handler) http.Handler {
Path: "/",
SameSite: http.SameSiteLaxMode,
Persist: true,
// Secure: cfg.Prod,
Secure: cfg.Prod,
}
return func(next http.Handler) http.Handler {

View File

@ -3,11 +3,13 @@ package server
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"git.maronato.dev/maronato/goshort/internal/config"
servermiddleware "git.maronato.dev/maronato/goshort/internal/server/middleware"
"git.maronato.dev/maronato/goshort/internal/util/logging"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"golang.org/x/sync/errgroup"
@ -26,10 +28,11 @@ func NewServer(cfg *config.Config) *Server {
mux := chi.NewRouter()
// Register default middlewares
mux.Use(middleware.Recoverer)
requestLogger := servermiddleware.NewLogFormatter()
mux.Use(middleware.RequestID)
mux.Use(middleware.RealIP)
mux.Use(middleware.Logger)
mux.Use(middleware.RequestLogger(requestLogger))
mux.Use(middleware.Recoverer)
mux.Use(servermiddleware.SessionManager(cfg))
mux.Use(middleware.Timeout(config.RequestTimeout))
mux.Use(middleware.Compress(5, "application/json"))
@ -51,11 +54,19 @@ func NewServer(cfg *config.Config) *Server {
}
func (s *Server) ListenAndServe(ctx context.Context) error {
l := logging.FromCtx(ctx)
// Create the errorgroup that will manage the server execution
eg, egCtx := errgroup.WithContext(ctx)
// Start the server
eg.Go(func() error {
l.Info("Starting server", slog.String("addr", s.srv.Addr))
// Use the global context for the server
s.srv.BaseContext = func(_ net.Listener) context.Context {
return egCtx
}
return s.srv.ListenAndServe()
})
@ -64,11 +75,17 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// Wait for the context to be done
<-egCtx.Done()
l.Info("Shutting down server")
return s.srv.Shutdown(
context.Background(),
)
})
s.srv.RegisterOnShutdown(func() {
l.Info("Server shutdown complete")
})
// Ignore the error if the context was canceled
if err := eg.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("server exited with error: %w", err)

View File

@ -2,23 +2,25 @@ package shortserver
import (
"errors"
"fmt"
"net/http"
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/server"
shortservice "git.maronato.dev/maronato/goshort/internal/service/short"
shortlogservice "git.maronato.dev/maronato/goshort/internal/service/shortlog"
"github.com/go-chi/chi/v5"
)
type ShortHandler struct {
service *shortservice.ShortService
shorts *shortservice.ShortService
shortLogs *shortlogservice.ShortLogService
}
func NewShortHandler(service *shortservice.ShortService) *ShortHandler {
func NewShortHandler(shorts *shortservice.ShortService, shortLogs *shortlogservice.ShortLogService) *ShortHandler {
return &ShortHandler{
service: service,
shorts: shorts,
shortLogs: shortLogs,
}
}
@ -29,26 +31,26 @@ func (h *ShortHandler) FindShort(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "short")
// Get the URL from the service
short, err := h.service.FindShort(ctx, name)
short, err := h.shorts.FindShort(ctx, name)
switch {
case err == nil:
// If there's no error, log the access and redirect to the URL
err = h.service.LogShortAccess(ctx, short, r)
if err != nil {
// If there was an error logging the access, print a message and
// continue.
fmt.Printf("failed to log short access: %v\n", err)
}
h.shortLogs.LogShortAccess(ctx, short, r)
http.Redirect(w, r, short.URL, http.StatusSeeOther)
case errors.Is(err, errs.ErrInvalidShort):
// If the short name is invalid, do nothing and let the static handler
// take care of it.
case errors.Is(err, errs.ErrShortDoesNotExist):
// If the short doesn't exist, do nothing and let the static handler
// take care of it.
default:
// Oops, this shouldn't happen.
server.RenderRender(w, r, server.ErrServerError(err))
server.RenderServerError(w, r, err)
}
}

View File

@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
@ -33,33 +32,6 @@ func NewShortService(db storage.Storage) *ShortService {
return &ShortService{db: db}
}
func (s *ShortService) LogShortAccess(ctx context.Context, short *models.Short, r *http.Request) error {
// Log the access
shortLog := &models.ShortLog{
ShortID: short.ID,
IPAddress: r.RemoteAddr,
UserAgent: r.UserAgent(),
Referer: r.Referer(),
}
err := s.db.CreateShortLog(ctx, shortLog)
if err != nil {
return fmt.Errorf("failed to log short access: %w", err)
}
return nil
}
func (s *ShortService) ListLogs(ctx context.Context, short *models.Short) ([]*models.ShortLog, error) {
// Get the logs from storage
logs, err := s.db.ListShortLogs(ctx, short)
if err != nil {
return nil, fmt.Errorf("failed to get short logs from storage: %w", err)
}
return logs, nil
}
func (s *ShortService) FindShort(ctx context.Context, name string) (*models.Short, error) {
// Check if the short is valid
err := ShortNameIsValid(name)

View File

@ -0,0 +1,88 @@
package shortlogservice
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/storage/models"
"git.maronato.dev/maronato/goshort/internal/util/logging"
)
type ShortLogService struct {
db storage.Storage
logCh chan *models.ShortLog
}
func NewShortLogService(db storage.Storage) *ShortLogService {
logCh := make(chan *models.ShortLog, 100)
return &ShortLogService{
db: db,
logCh: logCh,
}
}
func (s *ShortLogService) StartWorker(ctx context.Context) context.CancelFunc {
workerCtx, cancel := context.WithCancel(ctx)
// Start the goroutine
go s.shortLogWorker(workerCtx)
return cancel
}
func (s *ShortLogService) shortLogWorker(ctx context.Context) {
l := logging.FromCtx(ctx)
l.Debug("starting short log worker")
for {
select {
case <-ctx.Done():
l.Debug("stopping short log worker")
return
case shortLog := <-s.logCh:
l.Debug("writing short log to storage", slog.String("short_id", shortLog.ShortID))
err := s.db.CreateShortLog(ctx, shortLog)
if err != nil {
l.Warn("failed to log short access", "error", err)
}
}
}
}
func (s *ShortLogService) LogShortAccess(ctx context.Context, short *models.Short, r *http.Request) {
l := logging.FromCtx(ctx)
// Log the access
shortLog := &models.ShortLog{
ShortID: short.ID,
IPAddress: r.RemoteAddr,
UserAgent: r.UserAgent(),
Referer: r.Referer(),
CreatedAt: time.Now(),
}
l.Debug("adding short log to queue", slog.String("short_id", short.ID))
// Send the log to the channel
s.logCh <- shortLog
return
}
func (s *ShortLogService) ListLogs(ctx context.Context, short *models.Short) ([]*models.ShortLog, error) {
// Get the logs from storage
logs, err := s.db.ListShortLogs(ctx, short)
if err != nil {
return nil, fmt.Errorf("failed to get short logs from storage: %w", err)
}
return logs, nil
}

View File

@ -15,7 +15,7 @@ const (
// DefaultTokenLength is the default length of a token.
TokenLength = 32
// TokenPrefix is the prefix of a token.
TokenPrefix = "goshort-token:"
TokenPrefix = "gst_"
// DefaultTokenIDLength is the default length of a token ID.
TokenIDLength = 16
// MinTokenNameLength is the minimum length of a token name.
@ -83,10 +83,15 @@ func (s *TokenService) ListTokens(ctx context.Context, user *models.User) ([]*mo
}
// CreateToken creates a new token for a user.
func (s *TokenService) CreateToken(ctx context.Context, user *models.User) (*models.Token, error) {
func (s *TokenService) CreateToken(ctx context.Context, user *models.User, name string) (*models.Token, error) {
if name == "" {
// Make sure the name is valid
name = fmt.Sprintf("%s's token", user.Username)
}
// Generate a new token
token := &models.Token{
Name: fmt.Sprintf("%s's token", user.Username),
Name: name,
Value: TokenPrefix + tokenutil.GenerateSecureToken(TokenLength/2),
UserID: &user.ID,
}

View File

@ -46,7 +46,7 @@ type ShortModel struct {
// CreatedAt is when the short was created (initialized by the storage)
CreatedAt time.Time `bun:",notnull,default:current_timestamp" json:"createdAt"`
// DeletedAt is when the short was deleted
DeletedAt time.Time `bun:",null" json:"-"`
DeletedAt time.Time `bun:"," json:"-"`
// UserID is the ID of the user that created the short
// This can be null if the short was deleted

View File

@ -3,6 +3,7 @@ package bunstorage
import (
"context"
"database/sql"
"strings"
"time"
"git.maronato.dev/maronato/goshort/internal/config"
@ -143,6 +144,10 @@ func (s *BunStorage) Stop(ctx context.Context) error {
return s.db.Close()
}
func (s *BunStorage) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
func (s *BunStorage) FindShort(ctx context.Context, name string) (*models.Short, error) {
shortModel := new(ShortModel)
@ -199,6 +204,10 @@ func (s *BunStorage) CreateShort(ctx context.Context, short *models.Short) (*mod
Model(shortModel).
Exec(ctx)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
err = errs.ErrUserExists
}
return nil, errs.Errorf("failed to create short", err)
}
@ -240,9 +249,9 @@ func (s *BunStorage) ListShorts(ctx context.Context, user *models.User) ([]*mode
return nil, errs.Errorf("failed to list shorts", err)
}
shorts := []*models.Short{}
for _, shortModel := range shortModels {
shorts = append(shorts, shortModel.toShort())
shorts := make([]*models.Short, len(shortModels))
for i, shortModel := range shortModels {
shorts[i] = shortModel.toShort()
}
return shorts, nil
}
@ -269,6 +278,10 @@ func (s *BunStorage) CreateShortLog(ctx context.Context, shortLog *models.ShortL
Model(shortLogModel).
Exec(ctx)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
err = errs.ErrUserExists
}
return errs.Errorf("failed to create short log", err)
}
@ -285,9 +298,9 @@ func (s *BunStorage) ListShortLogs(ctx context.Context, short *models.Short) ([]
return nil, errs.Errorf("failed to list short logs", err)
}
shortLogs := []*models.ShortLog{}
for _, shortLogModel := range shortLogModels {
shortLogs = append(shortLogs, shortLogModel.toShortLog())
shortLogs := make([]*models.ShortLog, len(shortLogModels))
for i, shortLogModel := range shortLogModels {
shortLogs[i] = shortLogModel.toShortLog()
}
return shortLogs, nil
}
@ -330,6 +343,10 @@ func (s *BunStorage) CreateUser(ctx context.Context, user *models.User) (*models
Model(userModel).
Exec(ctx)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
err = errs.ErrUserExists
}
return nil, errs.Errorf("failed to create user", err)
}
@ -406,9 +423,9 @@ func (s *BunStorage) ListTokens(ctx context.Context, user *models.User) ([]*mode
return nil, errs.Errorf("failed to list tokens", err)
}
tokens := []*models.Token{}
for _, tokenModel := range tokenModels {
tokens = append(tokens, tokenModel.toToken())
tokens := make([]*models.Token, len(tokenModels))
for i, tokenModel := range tokenModels {
tokens[i] = tokenModel.toToken()
}
return tokens, nil
@ -435,6 +452,10 @@ func (s *BunStorage) CreateToken(ctx context.Context, token *models.Token) (*mod
Model(tokenModel).
Exec(ctx)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
err = errs.ErrUserExists
}
return nil, errs.Errorf("failed to create token", err)
}
@ -460,6 +481,7 @@ func (s *BunStorage) ChangeTokenName(ctx context.Context, token *models.Token, n
Model((*TokenModel)(nil)).
Set("name = ?", name).
Where("id = ?", token.ID).
Returning("*").
Exec(ctx, newToken)
if err != nil {
return nil, errs.Errorf("failed to change token name", err)

View File

@ -11,6 +11,7 @@ import (
"git.maronato.dev/maronato/goshort/internal/errs"
"git.maronato.dev/maronato/goshort/internal/storage"
"git.maronato.dev/maronato/goshort/internal/storage/models"
"git.maronato.dev/maronato/goshort/internal/util/logging"
)
// MemoryStorage is a storage that stores everything in memory.
@ -48,26 +49,28 @@ func NewMemoryStorage(cfg *config.Config) *MemoryStorage {
}
// logPerformance is a helper function to log the performance of a function.
func (s *MemoryStorage) logPerformance() func() {
func (s *MemoryStorage) logPerformance(ctx context.Context) func() {
start := time.Now()
return func() {
elapsed := time.Since(start)
took := time.Since(start)
if s.debug {
l := logging.FromCtx(ctx)
pc, _, _, ok := runtime.Caller(1)
if !ok {
return
}
method := runtime.FuncForPC(pc).Name()
fmt.Printf("%s took %s\n", method, elapsed)
l.Debug("MemoryStorage performance", "method", method, "took", took)
}
}
}
// Start starts the storage.
func (s *MemoryStorage) Start(ctx context.Context) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
return nil
@ -75,7 +78,14 @@ func (s *MemoryStorage) Start(ctx context.Context) error {
// Stop stops the storage.
func (s *MemoryStorage) Stop(ctx context.Context) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
return nil
}
func (s *MemoryStorage) Ping(ctx context.Context) error {
logPerf := s.logPerformance(ctx)
defer logPerf()
return nil
@ -83,7 +93,7 @@ func (s *MemoryStorage) Stop(ctx context.Context) error {
// FindShort finds a short in the storage.
func (s *MemoryStorage) FindShort(ctx context.Context, name string) (*models.Short, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortMu.RLock()
@ -99,7 +109,7 @@ func (s *MemoryStorage) FindShort(ctx context.Context, name string) (*models.Sho
// FindShortByID finds a short in the storage.
func (s *MemoryStorage) FindShortByID(ctx context.Context, id string) (*models.Short, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortIDMu.RLock()
@ -114,7 +124,7 @@ func (s *MemoryStorage) FindShortByID(ctx context.Context, id string) (*models.S
}
func (s *MemoryStorage) CreateShort(ctx context.Context, short *models.Short) (*models.Short, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortMu.Lock()
@ -146,7 +156,7 @@ func (s *MemoryStorage) CreateShort(ctx context.Context, short *models.Short) (*
}
func (s *MemoryStorage) DeleteShort(ctx context.Context, short *models.Short) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortMu.Lock()
@ -169,7 +179,7 @@ func (s *MemoryStorage) DeleteShort(ctx context.Context, short *models.Short) er
}
func (s *MemoryStorage) ListShorts(ctx context.Context, user *models.User) ([]*models.Short, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortIDMu.RLock()
@ -187,7 +197,7 @@ func (s *MemoryStorage) ListShorts(ctx context.Context, user *models.User) ([]*m
}
func (s *MemoryStorage) CreateShortLog(ctx context.Context, shortLog *models.ShortLog) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortLogMu.Lock()
@ -216,7 +226,7 @@ func (s *MemoryStorage) CreateShortLog(ctx context.Context, shortLog *models.Sho
}
func (s *MemoryStorage) ListShortLogs(ctx context.Context, short *models.Short) ([]*models.ShortLog, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.shortLogMu.RLock()
@ -231,7 +241,7 @@ func (s *MemoryStorage) ListShortLogs(ctx context.Context, short *models.Short)
}
func (s *MemoryStorage) FindUser(ctx context.Context, username string) (*models.User, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.userMu.RLock()
@ -246,7 +256,7 @@ func (s *MemoryStorage) FindUser(ctx context.Context, username string) (*models.
}
func (s *MemoryStorage) FindUserByID(ctx context.Context, id string) (*models.User, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.userIDMu.RLock()
@ -261,7 +271,7 @@ func (s *MemoryStorage) FindUserByID(ctx context.Context, id string) (*models.Us
}
func (s *MemoryStorage) CreateUser(ctx context.Context, user *models.User) (*models.User, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.userMu.Lock()
@ -293,7 +303,7 @@ func (s *MemoryStorage) CreateUser(ctx context.Context, user *models.User) (*mod
}
func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.userMu.Lock()
@ -343,7 +353,7 @@ func (s *MemoryStorage) DeleteUser(ctx context.Context, user *models.User) error
}
func (s *MemoryStorage) FindToken(ctx context.Context, value string) (*models.Token, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.tokenMu.RLock()
@ -358,7 +368,7 @@ func (s *MemoryStorage) FindToken(ctx context.Context, value string) (*models.To
}
func (s *MemoryStorage) FindTokenByID(ctx context.Context, id string) (*models.Token, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.tokenIDMu.RLock()
@ -373,7 +383,7 @@ func (s *MemoryStorage) FindTokenByID(ctx context.Context, id string) (*models.T
}
func (s *MemoryStorage) ListTokens(ctx context.Context, user *models.User) ([]*models.Token, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.tokenIDMu.RLock()
@ -391,7 +401,7 @@ func (s *MemoryStorage) ListTokens(ctx context.Context, user *models.User) ([]*m
}
func (s *MemoryStorage) CreateToken(ctx context.Context, token *models.Token) (*models.Token, error) {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.tokenMu.Lock()
@ -427,7 +437,7 @@ func (s *MemoryStorage) CreateToken(ctx context.Context, token *models.Token) (*
}
func (s *MemoryStorage) DeleteToken(ctx context.Context, token *models.Token) error {
logPerf := s.logPerformance()
logPerf := s.logPerformance(ctx)
defer logPerf()
s.tokenMu.Lock()

View File

@ -11,26 +11,20 @@ import (
bunstorage "git.maronato.dev/maronato/goshort/internal/storage/bun"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/driver/sqliteshim"
_ "modernc.org/sqlite"
)
// NewSQLiteStorage creates a new SQLite storage.
func NewSQLiteStorage(cfg *config.Config) storage.Storage {
// Create a new SQLite database with the following pragmas enabled:
// - journal_mode=WAL: Enables Write-Ahead Logging, which allows for concurrent reads and writes.
// - foreign_keys=ON: Enables foreign key constraints.
// - synchronous=NORMAL: Enables synchronous mode NORMAL
sqldb, err := sql.Open(sqliteshim.ShimName, cfg.DBURL+"?_pragma=foreign_keys(ON)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)")
if err != nil {
panic(err)
if cfg.DBType == config.DBTypeMemory {
cfg.DBURL = "file::memory:?cache=shared&mode=memory&_foreign_keys=1"
} else {
cfg.DBURL = cfg.DBURL + "?_pragma=journal_mode=WAL&_pragma=foreign_keys=1"
}
// If running the DB in memory, make sure
// database/sql does not close idle connections.
// Otherwise, the database will be lost.
if cfg.DBType == config.DBTypeMemory {
sqldb.SetMaxIdleConns(1000)
sqldb.SetConnMaxLifetime(0)
sqldb, err := sql.Open("sqlite", cfg.DBURL)
if err != nil {
panic(err)
}
db := bun.NewDB(sqldb, sqlitedialect.New())

View File

@ -10,6 +10,8 @@ type Storage interface {
// Lifecycle
Start(ctx context.Context) error
Stop(ctx context.Context) error
Ping(ctx context.Context) error
// Short Storage
// FindShort finds a short in the storage using its name.

View File

@ -0,0 +1,49 @@
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"git.maronato.dev/maronato/goshort/internal/config"
)
type logCtxKey struct{}
func NewLogger(cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
addSource := false
if cfg.Debug {
level = slog.LevelDebug
addSource = true
}
if cfg.Prod {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
AddSource: addSource,
}))
} else {
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
AddSource: addSource,
}))
}
}
func FromCtx(ctx context.Context) *slog.Logger {
l, ok := ctx.Value(logCtxKey{}).(*slog.Logger)
if !ok {
fmt.Println("No logger found in context")
return slog.New(NewNoOpHandler())
}
return l
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, logCtxKey{}, l)
}

View File

@ -0,0 +1,30 @@
package logging
import (
"context"
"log/slog"
)
type NoOpHandler struct {
slog.Handler
}
func NewNoOpHandler() slog.Handler {
return &NoOpHandler{}
}
func (h *NoOpHandler) Enabled(ctx context.Context, level slog.Level) bool {
return false
}
func (h *NoOpHandler) Handle(ctx context.Context, rec slog.Record) error {
return nil
}
func (h *NoOpHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return h
}
func (h *NoOpHandler) WithGroup(name string) slog.Handler {
return h
}