From 949ea57dd99f815d3dda5a14ddbaa0f0b13375e2 Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Thu, 24 Aug 2023 22:03:58 -0300 Subject: [PATCH] I'm done --- .dockerignore | 1 + .gitignore | 1 + .tool-versions | 1 + Dockerfile | 6 +- Makefile | 2 +- cmd/dev/dev.go | 19 +- cmd/healthcheck/healthcheck.go | 5 + cmd/main.go | 6 + cmd/serve/serve.go | 18 +- cmd/shared/shared.go | 2 +- frontend/package-lock.json | 292 +++---------------- frontend/package.json | 3 +- frontend/public/adjectives.txt | 153 ++++++++++ frontend/public/nouns.txt | 136 +++++++++ frontend/src/components/ItemList.tsx | 1 - frontend/src/components/Navbar.tsx | 2 +- frontend/src/components/NoItems.tsx | 11 - frontend/src/components/SessionItem.tsx | 73 ++++- frontend/src/components/TokenItem.tsx | 133 ++++++++- frontend/src/hooks/useCRUD.tsx | 80 ++--- frontend/src/hooks/useStats.ts | 2 +- frontend/src/pages/Index.tsx | 10 +- frontend/src/pages/Login.tsx | 2 +- frontend/src/pages/Sessions.tsx | 9 +- frontend/src/pages/ShortDetails.tsx | 2 +- frontend/src/pages/Signup.tsx | 2 +- frontend/src/pages/Tokens.tsx | 50 +++- frontend/src/util/date.ts | 9 + go.mod | 16 +- go.sum | 51 +++- internal/config/config.go | 5 +- internal/server/api/handler.go | 55 +++- internal/server/devui/server.go | 11 + internal/server/errors.go | 7 +- internal/server/healthcheck/handler.go | 58 +++- internal/server/middleware/logging.go | 74 +++++ internal/server/middleware/session.go | 2 +- internal/server/server.go | 21 +- internal/server/short/handler.go | 26 +- internal/service/short/shortservice.go | 28 -- internal/service/shortlog/shortlogservice.go | 88 ++++++ internal/service/token/tokenservice.go | 11 +- internal/storage/bun/models.go | 2 +- internal/storage/bun/storage.go | 40 ++- internal/storage/memory/memory.go | 52 ++-- internal/storage/sqlite/sqlite.go | 22 +- internal/storage/storage.go | 2 + internal/util/logging/logging.go | 49 ++++ internal/util/logging/noop.go | 30 ++ 49 files changed, 1179 insertions(+), 502 deletions(-) create mode 100644 .tool-versions create mode 100644 frontend/public/adjectives.txt create mode 100644 frontend/public/nouns.txt delete mode 100644 frontend/src/components/NoItems.tsx create mode 100644 frontend/src/util/date.ts create mode 100644 internal/server/middleware/logging.go create mode 100644 internal/service/shortlog/shortlogservice.go create mode 100644 internal/util/logging/logging.go create mode 100644 internal/util/logging/noop.go diff --git a/.dockerignore b/.dockerignore index 3abf069..3df95af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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: diff --git a/.gitignore b/.gitignore index 0e4390f..1f52a82 100644 --- a/.gitignore +++ b/.gitignore @@ -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: diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..27b4d7a --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.21.0 diff --git a/Dockerfile b/Dockerfile index f92dd27..b7ca8b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] diff --git a/Makefile b/Makefile index d0a5f14..ee2fe82 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/dev/dev.go b/cmd/dev/dev.go index d0200f3..2241611 100644 --- a/cmd/dev/dev.go +++ b/cmd/dev/dev.go @@ -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, })) diff --git a/cmd/healthcheck/healthcheck.go b/cmd/healthcheck/healthcheck.go index 89eb6ef..2b2590c 100644 --- a/cmd/healthcheck/healthcheck.go +++ b/cmd/healthcheck/healthcheck.go @@ -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", diff --git a/cmd/main.go b/cmd/main.go index 51da6d9..4cad4f3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index f7cc9c4..3f205bb 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -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) diff --git a/cmd/shared/shared.go b/cmd/shared/shared.go index e91c74f..5026e02 100644 --- a/cmd/shared/shared.go +++ b/cmd/shared/shared.go @@ -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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b66d17b..6bb1c8e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 820e47f..f63e079 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/adjectives.txt b/frontend/public/adjectives.txt new file mode 100644 index 0000000..c669f0c --- /dev/null +++ b/frontend/public/adjectives.txt @@ -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 diff --git a/frontend/public/nouns.txt b/frontend/public/nouns.txt new file mode 100644 index 0000000..552ff76 --- /dev/null +++ b/frontend/public/nouns.txt @@ -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 diff --git a/frontend/src/components/ItemList.tsx b/frontend/src/components/ItemList.tsx index 2c1631c..d721314 100644 --- a/frontend/src/components/ItemList.tsx +++ b/frontend/src/components/ItemList.tsx @@ -10,7 +10,6 @@ const ItemList = , K extends keyof T>({ idKey: K Item: FunctionComponent }) => { - console.log("list") return ( <>
    {item.name} diff --git a/frontend/src/components/NoItems.tsx b/frontend/src/components/NoItems.tsx deleted file mode 100644 index a79d92e..0000000 --- a/frontend/src/components/NoItems.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { FunctionComponent } from "react" - -const NoItems: FunctionComponent<{ type: string }> = ({ type }) => { - return ( -
    - {`No ${type}s yet`} -
    - ) -} - -export default NoItems diff --git a/frontend/src/components/SessionItem.tsx b/frontend/src/components/SessionItem.tsx index 48dfc4b..b1914be 100644 --- a/frontend/src/components/SessionItem.tsx +++ b/frontend/src/components/SessionItem.tsx @@ -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 + case "tablet": + return + case "smarttv": + return + default: + return + } +} + const SessionItem: FunctionComponent = ({ ...session }) => { // Handle deletion const [deleting, deleteOther] = useDelete() @@ -21,9 +45,54 @@ const SessionItem: FunctionComponent = ({ ...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 ( - {JSON.stringify(session)} +
    +
    +
    + +
    +
    + + {ua.browser} + + + {session.ipAddress} + +
    +
    +
    +
    + + {ua.os} {ua.osVersion} + + + Last access: {lastActivity} + +
    +
    +
    +
    + {session.current ? ( + + Current + + ) : null} +
    +
    +
    ) } diff --git a/frontend/src/components/TokenItem.tsx b/frontend/src/components/TokenItem.tsx index b3b5b05..83c45a9 100644 --- a/frontend/src/components/TokenItem.tsx +++ b/frontend/src/components/TokenItem.tsx @@ -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 = 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 = 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 ( +
    + {editing ? ( +
    + setNewName(e.target.value)} + autoFocus + /> + +
    + ) : ( + +
    + {name} +
    + +
    + )} +
    + ) +} + +const TokenValue: FunctionComponent<{ value: string }> = ({ value }) => { + const [show, setShow] = useState(false) + + const breakStyle = useMemo(() => { + return show ? {} : { wordBreak: "break-word" as const } + }, [show]) + + return ( +
    + + {show ? value : "*".repeat(value.length)} + + +
    + ) +} + const TokenItem: FunctionComponent = ({ ...token }) => { const [deleting, del] = useDelete() const doDelete = useCallback(() => del({ id: token.id }), [del, token.id]) + const createdAt = formatDateStr(token.createdAt) + return ( - {JSON.stringify(token)} +
    +
    + +
    +
    + Created at: {createdAt} +
    +
    + +
    +
    ) } diff --git a/frontend/src/hooks/useCRUD.tsx b/frontend/src/hooks/useCRUD.tsx index 19b51f8..a8b50a2 100644 --- a/frontend/src/hooks/useCRUD.tsx +++ b/frontend/src/hooks/useCRUD.tsx @@ -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 = >( return [creating, create] as const } -export const useCreate = ( - onCreate?: (payload?: T) => void -) => { - const submit = useSubmit() - const navigation = useNavigation() - const [creating, setCreating] = useState(false) +const makeUseVerb = + (verb: Uppercase) => + (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 = ( - 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") diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts index d2a03e2..df59264 100644 --- a/frontend/src/hooks/useStats.ts +++ b/frontend/src/hooks/useStats.ts @@ -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 diff --git a/frontend/src/pages/Index.tsx b/frontend/src/pages/Index.tsx index 14d03c9..4e1eec8 100644 --- a/frontend/src/pages/Index.tsx +++ b/frontend/src/pages/Index.tsx @@ -187,7 +187,7 @@ export const Component: FunctionComponent = () => { Choose a{" "} custom link -
    +
    {`${location.host}/`} @@ -212,9 +212,11 @@ export const Component: FunctionComponent = () => { {shortening ? "Shortening..." : "Shorten it"} -
    - -
    + {recentShorts.length ? ( +
    + +
    + ) : null} ) } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d31869c..8cada11 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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" /> - {items.length > 0 ? : } + ) } @@ -59,7 +76,14 @@ export const loader: LoaderFunction = async (args) => { } export const action = crudAction({ - POST: () => fetchAPI("/tokens", { method: "POST" }), + POST: async (formData) => { + const name = formData.get("name") as string | null + + return fetchAPI("/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 diff --git a/frontend/src/util/date.ts b/frontend/src/util/date.ts new file mode 100644 index 0000000..6948a59 --- /dev/null +++ b/frontend/src/util/date.ts @@ -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", + }) +} diff --git a/go.mod b/go.mod index cc11a19..6d31aee 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 769d1fe..77de3f2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 96c1091..acbc57c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/server/api/handler.go b/internal/server/api/handler.go index 22ac1cd..f176bc4 100644 --- a/internal/server/api/handler.go +++ b/internal/server/api/handler.go @@ -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 diff --git a/internal/server/devui/server.go b/internal/server/devui/server.go index 9a4d8ab..390e69a 100644 --- a/internal/server/devui/server.go +++ b/internal/server/devui/server.go @@ -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 }) diff --git a/internal/server/errors.go b/internal/server/errors.go index b4b27ac..fabb084 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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) { diff --git a/internal/server/healthcheck/handler.go b/internal/server/healthcheck/handler.go index 1f5bc25..d3c148b 100644 --- a/internal/server/healthcheck/handler.go +++ b/internal/server/healthcheck/handler.go @@ -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 diff --git a/internal/server/middleware/logging.go b/internal/server/middleware/logging.go new file mode 100644 index 0000000..6ad0a6f --- /dev/null +++ b/internal/server/middleware/logging.go @@ -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) +} diff --git a/internal/server/middleware/session.go b/internal/server/middleware/session.go index 15b555b..2ca8568 100644 --- a/internal/server/middleware/session.go +++ b/internal/server/middleware/session.go @@ -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 { diff --git a/internal/server/server.go b/internal/server/server.go index 46b3f75..96adbcc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/short/handler.go b/internal/server/short/handler.go index 13598b0..4dbf2e6 100644 --- a/internal/server/short/handler.go +++ b/internal/server/short/handler.go @@ -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) } } diff --git a/internal/service/short/shortservice.go b/internal/service/short/shortservice.go index a08a074..415c229 100644 --- a/internal/service/short/shortservice.go +++ b/internal/service/short/shortservice.go @@ -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) diff --git a/internal/service/shortlog/shortlogservice.go b/internal/service/shortlog/shortlogservice.go new file mode 100644 index 0000000..4b9a7f2 --- /dev/null +++ b/internal/service/shortlog/shortlogservice.go @@ -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 +} diff --git a/internal/service/token/tokenservice.go b/internal/service/token/tokenservice.go index e86c86b..28b74cf 100644 --- a/internal/service/token/tokenservice.go +++ b/internal/service/token/tokenservice.go @@ -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, } diff --git a/internal/storage/bun/models.go b/internal/storage/bun/models.go index 72b4d29..648b63b 100644 --- a/internal/storage/bun/models.go +++ b/internal/storage/bun/models.go @@ -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 diff --git a/internal/storage/bun/storage.go b/internal/storage/bun/storage.go index bf21ccd..301c41b 100644 --- a/internal/storage/bun/storage.go +++ b/internal/storage/bun/storage.go @@ -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) diff --git a/internal/storage/memory/memory.go b/internal/storage/memory/memory.go index 8e6f53e..6be4258 100644 --- a/internal/storage/memory/memory.go +++ b/internal/storage/memory/memory.go @@ -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() diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 3f62662..12854cb 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -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()) diff --git a/internal/storage/storage.go b/internal/storage/storage.go index ba753f4..faa3185 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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. diff --git a/internal/util/logging/logging.go b/internal/util/logging/logging.go new file mode 100644 index 0000000..128ca6b --- /dev/null +++ b/internal/util/logging/logging.go @@ -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) +} diff --git a/internal/util/logging/noop.go b/internal/util/logging/noop.go new file mode 100644 index 0000000..d899ce4 --- /dev/null +++ b/internal/util/logging/noop.go @@ -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 +}