diff --git a/.air.toml b/.air.toml index bc9b181..51aab3c 100644 --- a/.air.toml +++ b/.air.toml @@ -6,8 +6,9 @@ tmp_dir = "tmp" cmd = "make build" delay = 1000 exclude_file = ["README.md"] - exclude_regex = ["_templ.go", "_test.go", ".db"] - exclude_unchanged = false + exclude_regex = ["_templ\\.go", "_test\\.go", "\\.db"] + exclude_dir = ["bin", "tmp", "docs", "node_modules"] + exclude_unchanged = true follow_symlink = false full_bin = "" include_dir = [] diff --git a/.dockerignore b/.dockerignore index 0c103bd..7bddd6b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,10 @@ # Ref: https://docs.docker.com/reference/dockerfile/#dockerignore-file .git +node_modules +tmp +bin +*.db +docs +.air.toml +bun.lock +.deps-stamp diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8b046b9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.go] +indent_style = tab +tab_width = 4 + +[*.templ] +indent_style = tab +tab_width = 4 + +[*.{js,ts,jsx,tsx}] +indent_style = space +indent_size = 2 + +[Makefile] +indent_style = tab + +[Dockerfile] +indent_style = space +indent_size = 2 + +[*.{md,txt}] +indent_style = space +indent_size = 2 +max_line_length = off + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.css] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index ba1b47f..f352bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ tmp node_modules internal/web/static/css/* !internal/web/static/css/input.css -internal/web/static/js/ +internal/web/static/js/* +!internal/web/static/js/app.js .DS_Store @@ -14,3 +15,6 @@ internal/web/**/*_templ.go internal/web/**/*_templ.txt *.db + +# Ignore generated swagger docs +docs/ diff --git a/Dockerfile b/Dockerfile index 2ddea1a..1ad0cc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,6 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo/ COPY --from=builder /app/bin/app / -EXPOSE 8089 +EXPOSE 3000 ENTRYPOINT ["/app"] diff --git a/Makefile b/Makefile index d2f278e..b186e50 100644 --- a/Makefile +++ b/Makefile @@ -9,12 +9,12 @@ all: help ## build: Compile templ files and build application .PHONY: build build: prepare-data - CGO_ENABLED=0 go build -ldflags="-s -w -extldflags '-static'" -trimpath -o 'bin/app' ./cmd/app + CGO_ENABLED=0 go build -tags swagger -ldflags="-s -w -extldflags '-static'" -trimpath -o 'bin/app' ./cmd/app ## start: Build and start application .PHONY: start start: prepare-data - go run ./cmd/app + go run -tags swagger ./cmd/app ## dev: Build and start application in live reload mode .PHONY: dev @@ -28,11 +28,11 @@ build-docker: ## run-docker: Run Docker container image with this app .PHONY: run-docker run-docker: - docker run --rm -it -p 8089:8089 $(shell basename $(PWD)):latest + docker run --rm -it -p 3000:3000 $(shell basename $(PWD)):latest ## prepare-data: Prepare data for the application .PHONY: prepare-data -prepare-data: .deps-stamp get-js-deps generate-web +prepare-data: .deps-stamp get-js-deps generate-web generate-swagger .deps-stamp: go.mod go.sum go mod download @@ -66,6 +66,11 @@ test: check-go generate-web: check-go go tool templ generate +## generate-swagger: Generate swagger documentation via swaggo/swag +.PHONY: generate-swagger +generate-swagger: check-go + go tool swag init --dir ./cmd/app,./internal/web/handlers -g main.go --parseDependency --parseInternal + ## air: Build and start application in live reload mode via air .PHONY: air air: prepare-data diff --git a/README.md b/README.md index 5eadd24..2882ff5 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,28 @@ Example of full-stack Go based Web app -[![GO](https://img.shields.io/badge/go-%233366CC.svg?logo=go&logoColor=white)](https://go.dev) [![templ](https://img.shields.io/badge/templ-%233366CC.svg?logo=htmx&logoColor=white)](https://github.com/a-h/templ) [![HTMX](https://img.shields.io/badge/htmx-%233366CC.svg?logo=htmx&logoColor=white)](https://github.com/bigskysoftware/htmx) [![SQLite](https://img.shields.io/badge/sqlite-%233366CC.svg?logo=sqlite&logoColor=white)](https://gitlab.com/cznic/sqlite) [![GORM](https://img.shields.io/badge/gorm-%233366CC.svg?logo=sqlite&logoColor=white)](https://github.com/go-gorm/gorm) [![tailwindcss](https://img.shields.io/badge/tailwindcss-%233366CC.svg?logo=tailwindcss&logoColor=white)](https://github.com/tailwindlabs/tailwindcss) [![daisyui](https://img.shields.io/badge/daisyui-%233366CC.svg?logo=daisyui&logoColor=white)](https://daisyui.com) [![ionicons](https://img.shields.io/badge/ionicons-%233366CC.svg?logo=ionic&logoColor=white)](https://github.com/ionic-team/ionicons) +[![GO](https://img.shields.io/badge/go-%233366CC.svg?logo=go&logoColor=white)](https://go.dev) [![fiber](https://img.shields.io/badge/fiber-%233366CC.svg?logo=go&logoColor=white)](https://github.com/gofiber/fiber) [![templ](https://img.shields.io/badge/templ-%233366CC.svg?logo=htmx&logoColor=white)](https://github.com/a-h/templ) [![HTMX](https://img.shields.io/badge/htmx-%233366CC.svg?logo=htmx&logoColor=white)](https://github.com/bigskysoftware/htmx) [![SQLite](https://img.shields.io/badge/sqlite-%233366CC.svg?logo=sqlite&logoColor=white)](https://gitlab.com/cznic/sqlite) [![GORM](https://img.shields.io/badge/gorm-%233366CC.svg?logo=sqlite&logoColor=white)](https://github.com/go-gorm/gorm) [![tailwindcss](https://img.shields.io/badge/tailwindcss-%233366CC.svg?logo=tailwindcss&logoColor=white)](https://github.com/tailwindlabs/tailwindcss) [![daisyui](https://img.shields.io/badge/daisyui-%233366CC.svg?logo=daisyui&logoColor=white)](https://daisyui.com) [![ionicons](https://img.shields.io/badge/ionicons-%233366CC.svg?logo=ionic&logoColor=white)](https://github.com/ionic-team/ionicons) Features: - Comfortable and flexible component based templates via [templ](https://github.com/a-h/templ) - CRUD functionality (Create, Read, Update, and Delete entries) - Persistent storage via [SQLite](https://gitlab.com/cznic/sqlite) + ORM ([gorm](https://github.com/go-gorm/gorm)) +- User friendly interface with interactive Modals for better UX - Error handling on server and user interface side - Infinite Scrolling via lazy loading -- User friendly interface -- Interactive Modals for better UX +- Security configration - Native light and dark mode support - Preserve static files +- Swagger API documentation via [swaggo](https://github.com/swaggo/swag) + +## Security + +- **CSRF Protection** — session-based tokens via [Fiber](https://github.com/gofiber/fiber) middleware (Synchronizer Token Pattern) +- **SQL Injection Prevention** — all database queries use [gorm](https://github.com/go-gorm/gorm) parameterized bindings +- **Content-Security-Policy** — restricts resource loading +- **Input Validation** — server-side length limits and empty checks with field-level error reporting +- **Secure Cookies** — `HttpOnly`, `SameSite=Lax`, session-scoped for CSRF and session tokens ## Quick start @@ -32,7 +41,7 @@ make start make build && bin/app ``` -The server starts on `:8089`. +The server starts on `:3000`. The SQLite database is created automatically and migrations are applied on startup. diff --git a/bun.lock b/bun.lock index 8ce3da1..4866612 100644 --- a/bun.lock +++ b/bun.lock @@ -5,14 +5,14 @@ "": { "name": "go-full-stack-example", "dependencies": { - "htmx-ext-response-targets": "^2.0.4", - "htmx.org": "^2.0.7", - "ionicons": "^8.0.13", + "htmx-ext-response-targets": "2.0.4", + "htmx.org": "2.0.8", + "ionicons": "8.0.13", }, "devDependencies": { - "@tailwindcss/cli": "^4.2.2", + "@tailwindcss/cli": "4.2.2", "daisyui": "5.5.19", - "tailwindcss": "^4.2.2", + "tailwindcss": "4.2.2", }, }, }, diff --git a/cmd/app/main.go b/cmd/app/main.go index a4ee4e6..bfc8e2a 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,19 +1,74 @@ package main import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + "github.com/sonjek/go-full-stack-example/internal/service" "github.com/sonjek/go-full-stack-example/internal/storage" "github.com/sonjek/go-full-stack-example/internal/web" "github.com/sonjek/go-full-stack-example/internal/web/handlers" ) +// Number of notes to load per page for lazy loading +const defaultPageSize = 4 + func main() { - db := storage.NewDbStorage() - storage.DBMigrate(db) - storage.SeedData(db) + db, err := storage.NewDbStorage() + if err != nil { + exitOnError("Failed to initialize database", err) + } + if err := storage.DBMigrate(db); err != nil { + exitOnError("Failed to run database migrations", err) + } + if err := storage.SeedData(db); err != nil { + exitOnError("Failed to seed database", err) + } - noteService := service.NewNoteService(db) - appHandlers := handlers.NewHandler(db, noteService) + noteService := service.NewNoteService(db, defaultPageSize) + appHandlers := handlers.NewHandler(noteService) webServer := web.NewServer(appHandlers) - webServer.Start() + webServer.SetupMiddleware() + if err := webServer.SetupRoutes(); err != nil { + exitOnError("Failed to setup routes", err) + } + + // Run web server in a goroutine for graceful shutdown + go func() { + if err := webServer.Start(); err != nil { + slog.Error("Server error", "error", err) + } + }() + + // Wait for shutdown signal + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + <-ctx.Done() + stop() + + slog.Info("Shutting down...") + + // Create new context with timeout to let the web server finish its work + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + if err := webServer.Shutdown(shutdownCtx); err != nil { + slog.Error("Server shutdown error", "error", err) + } + + if sqlDB, err := db.DB(); err == nil { + if err := sqlDB.Close(); err != nil { + slog.Error("Failed to close database connection", "error", err) + } + } +} + +func exitOnError(msg string, err error) { + slog.Error(msg, "error", err) + os.Exit(1) //nolint:revive // Helper function for main package } diff --git a/go.mod b/go.mod index 47c9650..6eeca84 100644 --- a/go.mod +++ b/go.mod @@ -5,44 +5,80 @@ go 1.26.1 require ( github.com/a-h/templ v0.3.1001 github.com/dustin/go-humanize v1.0.1 + github.com/gofiber/contrib/v3/monitor v1.0.1 + github.com/gofiber/contrib/v3/swaggo v1.0.1 + github.com/gofiber/fiber/v3 v3.1.0 github.com/stretchr/testify v1.11.1 + github.com/swaggo/swag v1.16.6 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 - modernc.org/sqlite v1.48.0 + modernc.org/sqlite v1.48.1 ) require ( dario.cat/mergo v1.0.2 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/air-verse/air v1.63.1 // indirect - github.com/andybalholm/brotli v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect github.com/bep/godartsass/v2 v2.5.0 // indirect github.com/bep/golibsass v1.2.0 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/gofiber/schema v1.7.0 // indirect + github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.159.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-sqlite3 v1.14.38 // indirect + github.com/mattn/go-sqlite3 v1.14.40 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect + github.com/swaggo/files/v2 v2.0.2 // indirect github.com/tdewolff/parse/v2 v2.8.11 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/urfave/cli/v2 v2.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect @@ -50,13 +86,16 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) tool ( github.com/a-h/templ/cmd/templ github.com/air-verse/air + github.com/swaggo/swag/cmd/swag ) diff --git a/go.sum b/go.sum index 68a3d4f..5fa5038 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,13 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU= github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0/go.mod h1:D56Cl9r8M5i3UwAchE+LlLc5hPN3kJtdZNVJn06lSHU= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= @@ -14,8 +17,8 @@ github.com/air-verse/air v1.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw= github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= -github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= @@ -62,12 +65,18 @@ github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+Urai github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/evanw/esbuild v0.27.4 h1:8opEixKkH9EDsdjxC/aPmpk1KPwQOcyknDo5m5xIFxI= github.com/evanw/esbuild v0.27.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -77,18 +86,56 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofiber/contrib/v3/monitor v1.0.1 h1:cHTLCc3SkM3TiyNYbns07PrLBud1LBDy4QwW0/Ve7Nk= +github.com/gofiber/contrib/v3/monitor v1.0.1/go.mod h1:xn2ZkTbYjYaWBbEZCMzLz8BGgCe4VWfpaYtiesukazw= +github.com/gofiber/contrib/v3/swaggo v1.0.1 h1:b1pj5CcZhXkj9k2vcO1AnwQ/zE7nva070lV/Pv28WdI= +github.com/gofiber/contrib/v3/swaggo v1.0.1/go.mod h1:H1ipNwVlmVepEPP0ydKhXiSQRHWObDNMi9KaT8njs6o= +github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= +github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= +github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= +github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= +github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/gohugoio/gift v0.2.0 h1:vA31pP0rTVmBxBrhpY3WEt+4zM4g+1sDqYeemwsYeqc= github.com/gohugoio/gift v0.2.0/go.mod h1:1Mrm5CjF33KpD749Dwj+UAjWZ3LC6cBXGuTMa5XwoP4= github.com/gohugoio/go-i18n/v2 v2.1.3-0.20251018145728-cfcc22d823c6 h1:pxlAea9eRwuAnt/zKbGqlFO2ZszpIe24YpOVLf+N+4I= @@ -126,6 +173,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -135,6 +184,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U= github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= @@ -147,8 +198,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= -github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= +github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= @@ -183,20 +234,37 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= +github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= +github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/tdewolff/minify/v2 v2.24.11 h1:JlANsiWaRBXedoYtsiZgY3YFkdr42oF32vp2SLgQKi4= github.com/tdewolff/minify/v2 v2.24.11/go.mod h1:exq1pjdrh9uAICdfVKQwqz6MsJmWmQahZuTC6pTO6ro= github.com/tdewolff/parse/v2 v2.8.11 h1:SGyjEy3xEqd+W9WVzTlTQ5GkP/en4a1AZNZVJ1cvgm0= @@ -205,12 +273,34 @@ github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA= github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= @@ -219,6 +309,9 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -231,6 +324,9 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= @@ -259,11 +355,13 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= -modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= +modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/service/notes.go b/internal/service/notes.go index b6a492d..5f414aa 100644 --- a/internal/service/notes.go +++ b/internal/service/notes.go @@ -1,65 +1,128 @@ package service import ( + "errors" + "fmt" + database "github.com/sonjek/go-full-stack-example/internal/storage" "gorm.io/gorm" + "gorm.io/gorm/clause" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" +) + +const MsgTitleAlreadyExists = "Title already used by another note" + +var ( + ErrRecordNotFound = fmt.Errorf("record not found: %w", gorm.ErrRecordNotFound) + ErrDuplicateTitle = errors.New("duplicate title: title already used by another note") ) type NoteService struct { - db *gorm.DB + db *gorm.DB + pageSize int } -func NewNoteService(db *gorm.DB) *NoteService { +func NewNoteService(db *gorm.DB, pageSize int) *NoteService { return &NoteService{ - db: db, + db: db, + pageSize: pageSize, } } -func (s *NoteService) LoadMore(cursorID, pageSize int) ([]database.Note, error) { +func (s *NoteService) LoadMore(cursorID int) ([]database.Note, error) { var notes []database.Note var result *gorm.DB if cursorID < 1 { - result = s.db.Limit(pageSize).Order("id DESC").Find(¬es) + result = s.db.Limit(s.pageSize).Order("id DESC").Find(¬es) } else { - result = s.db.Where("id < ?", cursorID).Order("id DESC").Limit(pageSize).Find(¬es) + result = s.db.Where("id < ?", cursorID).Order("id DESC").Limit(s.pageSize).Find(¬es) } if result.Error != nil { - return notes, result.Error + return nil, result.Error } return notes, nil } -func (s *NoteService) Create(title, body string) database.Note { +func (s *NoteService) Create(title, body string) (database.Note, error) { note := database.Note{ Title: title, Body: body, } - s.db.Create(¬e) + if err := s.db.Create(¬e).Error; err != nil { + if isUniqueConstraintViolation(err) { + return database.Note{}, ErrDuplicateTitle + } + return database.Note{}, err + } + return note, nil +} - return note +func (s *NoteService) Get(noteID int) (database.Note, error) { + var note database.Note + result := s.db.First(¬e, noteID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return database.Note{}, ErrRecordNotFound + } + return database.Note{}, result.Error + } + return note, nil } -func (s *NoteService) Get(noteID int) database.Note { - note := database.Note{ID: noteID} - s.db.First(¬e) - return note +func (s *NoteService) FindAndUpdate(noteID int, title, body string) (database.Note, error) { + var note database.Note + + updateData := map[string]any{ + "title": title, + "body": body, + } + + // Updates the record and populates 'note' via RETURNING clause + result := s.db.Model(¬e). + Clauses(clause.Returning{}). + Where("id = ?", noteID). + Updates(updateData) + + if result.Error != nil { + if isUniqueConstraintViolation(result.Error) { + return database.Note{}, ErrDuplicateTitle + } + return database.Note{}, result.Error + } + + if result.RowsAffected == 0 { + return database.Note{}, ErrRecordNotFound + } + + return note, nil } -func (s *NoteService) Update(note database.Note, title, body string) { - note.Title = title - note.Body = body - s.db.Save(¬e) +func (s *NoteService) Delete(noteID int) error { + result := s.db.Delete(&database.Note{}, noteID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrRecordNotFound + } + return nil } -func (s *NoteService) FindAndUpdate(noteID int, title, body string) database.Note { - note := s.Get(noteID) - s.Update(note, title, body) - return s.Get(noteID) +func IsRecordNotFound(err error) bool { + return errors.Is(err, ErrRecordNotFound) } -func (s *NoteService) Delete(noteID string) { - s.db.Delete(&database.Note{}, noteID) +func IsDuplicateTitle(err error) bool { + return errors.Is(err, ErrDuplicateTitle) +} + +func isUniqueConstraintViolation(err error) bool { + if sqliteErr, ok := errors.AsType[*sqlite.Error](err); ok { + return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE + } + return false } diff --git a/internal/storage/database.go b/internal/storage/database.go index 26fbffb..3a442b1 100644 --- a/internal/storage/database.go +++ b/internal/storage/database.go @@ -1,38 +1,56 @@ package storage import ( - _ "embed" //nolint:blank-imports + "os" "gorm.io/driver/sqlite" "gorm.io/gorm" + "gorm.io/gorm/logger" _ "modernc.org/sqlite" //nolint:blank-imports ) -// Can be set to file::memory:?cache=shared -const dataSourceName string = "file:sqlite.db?cache=shared&mode=rwc" +const defaultDSN = "file:sqlite.db?cache=shared&mode=rwc" -func NewDbStorage() *gorm.DB { +func NewDbStorage() (*gorm.DB, error) { + dsn := os.Getenv("DB_DSN") + if dsn == "" { + dsn = defaultDSN + } + return openDb(dsn) +} + +func NewInMemoryDbStorage() (*gorm.DB, error) { + return openDb(":memory:") +} + +func openDb(dsn string) (*gorm.DB, error) { db, err := gorm.Open(sqlite.New(sqlite.Config{ - DSN: dataSourceName, + DSN: dsn, DriverName: "sqlite", - }), &gorm.Config{}) + }), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) if err != nil { - panic(err) + return nil, err } - return db + return db, nil } -func DBMigrate(db *gorm.DB) { - if err := db.AutoMigrate(&Note{}); err != nil { - panic("Failed to run migrations:" + err.Error()) - } +func DBMigrate(db *gorm.DB) error { + return db.AutoMigrate(&Note{}) } -func SeedData(db *gorm.DB) { +func SeedData(db *gorm.DB) error { var count int64 - db.Model(&Note{}).Count(&count) + if err := db.Unscoped().Model(&Note{}).Count(&count).Error; err != nil { + return err + } if count == 0 { - db.Create(&NotesSeed) + for i := range NotesSeed { + NotesSeed[i].UpdatedAt = NotesSeed[i].CreatedAt + } + return db.Create(&NotesSeed).Error } + return nil } diff --git a/internal/storage/note.go b/internal/storage/seed.go similarity index 68% rename from internal/storage/note.go rename to internal/storage/seed.go index 78c540d..66ee3ee 100644 --- a/internal/storage/note.go +++ b/internal/storage/seed.go @@ -2,11 +2,14 @@ package storage import ( "time" - - "gorm.io/gorm" ) var NotesSeed = []Note{ + { + Title: "Swagger", + Body: "Swagger is a tool for generating API documentation from code.", + CreatedAt: time.Date(2013, 8, 20, 12, 8, 0, 0, time.UTC), + }, { Title: "gorm", Body: "The fantastic ORM library for Golang, aims to be developer friendly.", @@ -42,6 +45,12 @@ var NotesSeed = []Note{ Body: "A language for writing HTML user interfaces in Go.", CreatedAt: time.Date(2021, 5, 16, 22, 33, 0, 0, time.UTC), }, + { + Title: "Bun", + Body: "Incredibly fast, modern runtime, bundler, " + + "test runner and package manager for JavaScript/TypeScript.", + CreatedAt: time.Date(2021, 9, 14, 12, 48, 0, 0, time.UTC), + }, { Title: "htmx.js", Body: " htmx - high power tools for HTML.", @@ -54,11 +63,12 @@ var NotesSeed = []Note{ }, } +// Note represents a note entity in the database. +// @Description Note entity with title, body, and timestamps. type Note struct { - ID int `form:"id" json:"id" gorm:"primaryKey"` - Title string `form:"title" json:"title" binding:"required"` - Body string `form:"body" json:"body" binding:"required"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index"` + ID int `json:"id" gorm:"primaryKey"` + Title string `json:"title" gorm:"uniqueIndex"` + Body string `json:"body"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/web/handlers/base.go b/internal/web/handlers/base.go index 380fe7d..b877685 100644 --- a/internal/web/handlers/base.go +++ b/internal/web/handlers/base.go @@ -1,44 +1,72 @@ package handlers import ( - "fmt" + "log/slog" "net/http" "github.com/a-h/templ" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/csrf" "github.com/sonjek/go-full-stack-example/internal/service" "github.com/sonjek/go-full-stack-example/internal/web/templ/components" "github.com/sonjek/go-full-stack-example/internal/web/templ/page" "github.com/sonjek/go-full-stack-example/internal/web/templ/view" - "gorm.io/gorm" ) type Handlers struct { - db *gorm.DB noteService *service.NoteService } -func NewHandler(db *gorm.DB, ns *service.NoteService) *Handlers { +func NewHandler(ns *service.NoteService) *Handlers { return &Handlers{ noteService: ns, - db: db, } } -func handleRenderError(err error) { - if err != nil { - fmt.Println("Render error: ", err) +func getCSRFToken(c fiber.Ctx) string { + return csrf.TokenFromContext(c) +} + +func render(c fiber.Ctx, statusCode int, component templ.Component) error { + buf := templ.GetBuffer() + defer templ.ReleaseBuffer(buf) + + if err := component.Render(c.Context(), buf); err != nil { + slog.Error("Template render error", "error", err) + return err } + c.Status(statusCode) + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Send(buf.Bytes()) +} + +func sendErrorMsg(c fiber.Ctx, errorMsg string) error { + return render(c, http.StatusBadRequest, components.ErrorMsg(errorMsg)) +} + +type fieldErrors map[string]string + +func sendFieldErrors(c fiber.Ctx, errors fieldErrors) error { + c.Status(http.StatusBadRequest) + c.Type("json") + return c.JSON(fieldErrorsResponse{Errors: errors}) +} + +type fieldErrorsResponse struct { + Errors fieldErrors `json:"errors"` } -// Set header and render error message -func sendErrorMsg(w http.ResponseWriter, r *http.Request, errorMsg string) { - w.WriteHeader(http.StatusBadRequest) - handleRenderError(components.ErrorMsg(errorMsg).Render(r.Context(), w)) +// Health godoc +// @Summary Health check +// @Description Returns 200 OK for container orchestrators +// @Tags health +// @Produce json +// @Success 200 {string} string "OK" +// @Router /health [get] +func (h *Handlers) Health(c fiber.Ctx) error { + return c.SendStatus(http.StatusOK) } -func (h *Handlers) Page404(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - templ.Handler( - page.Index(view.NotFoundComponent()), - ).ServeHTTP(w, r) +func (h *Handlers) Page404(c fiber.Ctx) error { + return render(c, http.StatusNotFound, page.Index(view.NotFoundComponent(), getCSRFToken(c))) } diff --git a/internal/web/handlers/base_test.go b/internal/web/handlers/base_test.go index cf8fb6a..0876d78 100644 --- a/internal/web/handlers/base_test.go +++ b/internal/web/handlers/base_test.go @@ -4,67 +4,453 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/extractors" + "github.com/gofiber/fiber/v3/middleware/csrf" + "github.com/gofiber/fiber/v3/middleware/session" + "github.com/sonjek/go-full-stack-example/internal/service" + "github.com/sonjek/go-full-stack-example/internal/storage" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_sendErrorMsg(t *testing.T) { - testCases := []struct { - name string - errorMsg string - body io.Reader - expectedStatus int - }{ - { - name: "invalid request", - body: nil, - errorMsg: "MSG", - expectedStatus: http.StatusBadRequest, - }, +func testHandlers(t *testing.T) *Handlers { + t.Helper() + db, err := storage.NewInMemoryDbStorage() + if err != nil { + t.Fatal(err) + } + if err := storage.DBMigrate(db); err != nil { + t.Fatal(err) + } + return NewHandler(service.NewNoteService(db, 2)) +} + +func testApp(t *testing.T) *fiber.App { + t.Helper() + app := fiber.New() + sessionMiddleware, sessionStore := session.NewWithStore() + app.Use(sessionMiddleware) + app.Use(csrf.New(csrf.Config{ + Extractor: extractors.Chain( + extractors.FromHeader("X-Csrf-Token"), + extractors.FromForm("_csrf"), + ), + Session: sessionStore, + })) + h := testHandlers(t) + app.Get("/notes", h.Notes) + app.Get("/notes/load-more", h.LoadMoreNotes) + app.Get("/add", h.CreateNoteModal) + app.Post("/notes", h.CreateNote) + app.Get("/edit/:id", h.EditNoteModal) + app.Put("/notes/:id", h.EditNote) + app.Delete("/notes/:id", h.DeleteNote) + return app +} + +func getCSRFCookie(app *fiber.App) (cookie, token string) { + req := httptest.NewRequest(http.MethodGet, "/add", nil) + resp, _ := app.Test(req) + cookies := []string{} + for _, c := range resp.Cookies() { + cookies = append(cookies, c.Name+"="+c.Value) + if c.Name == "csrf_" { + token = c.Value + } } + cookie = strings.Join(cookies, "; ") + return cookie, token +} - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", tc.body) +func Test_sendErrorMsg(t *testing.T) { + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + return sendErrorMsg(c, "MSG") + }) - sendErrorMsg(w, r, tc.errorMsg) + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp, err := app.Test(req) - assert.Equal(t, tc.expectedStatus, w.Result().StatusCode, - "unexpected status code. Expected :%d, got: %d", tc.expectedStatus, w.Result().StatusCode) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) - body, _ := io.ReadAll(w.Result().Body) - bodyStr := string(body) - assert.Contains(t, bodyStr, tc.errorMsg, "error message should be in response") - assert.Contains(t, bodyStr, "Error:", "should contain Error label") - }) - } + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + assert.Contains(t, bodyStr, "MSG", "error message should be in response") + assert.Contains(t, bodyStr, "Error:", "should contain Error label") } func Test_Page404(t *testing.T) { - testCases := []struct { - name string - body io.Reader - expectedStatus int - }{ - { - name: "not found page", - body: nil, - expectedStatus: http.StatusNotFound, - }, - } + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + return testHandlers(t).Page404(c) + }) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", tc.body) + req := httptest.NewRequest(http.MethodGet, "/", nil) + resp, err := app.Test(req) - handler := &Handlers{} - handler.Page404(w, r) + require.NoError(t, err) + assert.Equal(t, fiber.StatusNotFound, resp.StatusCode) +} - assert.Equal(t, tc.expectedStatus, w.Result().StatusCode, - "unexpected status code. Expected :%d, got: %d", tc.expectedStatus, w.Result().StatusCode) - }) - } +func Test_Notes(t *testing.T) { + t.Run("empty list", func(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/notes", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("invalid cursor", func(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/notes?cursor=abc", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Invalid cursor parameter") + }) +} + +func Test_LoadMoreNotes(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/notes/load-more", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func Test_CreateNoteModal(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/add", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func Test_CreateNote_Success(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "Test Note") + form.Set("body", "Test body content") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Test Note") +} + +func Test_CreateNote_EmptyTitle(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "") + form.Set("body", "Test body") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func Test_CreateNote_EmptyBody(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "Test") + form.Set("body", "") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func Test_CreateNote_DuplicateTitle(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "Duplicate") + form.Set("body", "First") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cookie, token = getCSRFCookie(app) + form.Set("_csrf", token) + + req = httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err = app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), service.MsgTitleAlreadyExists) +} + +func Test_CreateNote_WhitespaceOnlyTitle(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", " ") + form.Set("body", "Test body") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func Test_CreateNote_MissingCSRF(t *testing.T) { + app := testApp(t) + + form := url.Values{} + form.Set("title", "Test") + form.Set("body", "Test body") + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func Test_EditNoteModal(t *testing.T) { + t.Run("invalid ID", func(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/edit/abc", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("not found", func(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodGet, "/edit/999", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Note not found") + }) +} + +func Test_EditNote(t *testing.T) { + t.Run("success", func(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "Original") + form.Set("body", "Original body") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cookie, token = getCSRFCookie(app) + form = url.Values{} + form.Set("title", "Updated") + form.Set("body", "Updated body") + form.Set("_csrf", token) + + req = httptest.NewRequest(http.MethodPut, "/notes/1", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err = app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Updated") + }) + + t.Run("not found", func(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "Updated") + form.Set("body", "Updated body") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPut, "/notes/999", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Note not found") + }) + + t.Run("missing CSRF token", func(t *testing.T) { + app := testApp(t) + + form := url.Values{} + form.Set("title", "Updated") + form.Set("body", "Updated body") + + req := httptest.NewRequest(http.MethodPut, "/notes/1", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) +} + +func Test_DeleteNote(t *testing.T) { + t.Run("success", func(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + form := url.Values{} + form.Set("title", "To Delete") + form.Set("body", "Delete me") + form.Set("_csrf", token) + + req := httptest.NewRequest(http.MethodPost, "/notes", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + cookie, token = getCSRFCookie(app) + + req = httptest.NewRequest(http.MethodDelete, "/notes/1", nil) + req.Header.Set("X-Csrf-Token", token) + req.Header.Set("Cookie", cookie) + resp, err = app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("not found", func(t *testing.T) { + app := testApp(t) + cookie, token := getCSRFCookie(app) + + req := httptest.NewRequest(http.MethodDelete, "/notes/999", nil) + req.Header.Set("X-Csrf-Token", token) + req.Header.Set("Cookie", cookie) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + body, _ := io.ReadAll(resp.Body) + assert.Contains(t, string(body), "Note not found") + }) + + t.Run("missing CSRF token", func(t *testing.T) { + app := testApp(t) + + req := httptest.NewRequest(http.MethodDelete, "/notes/1", nil) + resp, err := app.Test(req) + + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) +} + +func Test_ParseNoteID(t *testing.T) { + app := fiber.New() + app.Get("/notes/:id", func(c fiber.Ctx) error { + id, err := parseNoteID(c) + if err != nil { + return c.Status(http.StatusBadRequest).SendString(err.Error()) + } + return c.SendString(strings.Repeat("0", id)) + }) + + t.Run("valid ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/notes/5", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("invalid format", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/notes/abc", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("zero ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/notes/0", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("negative ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/notes/-1", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) } diff --git a/internal/web/handlers/notes.go b/internal/web/handlers/notes.go index 0e68529..2b9b3d8 100644 --- a/internal/web/handlers/notes.go +++ b/internal/web/handlers/notes.go @@ -2,158 +2,248 @@ package handlers import ( "errors" + "log/slog" "net/http" "strconv" - "time" + "strings" + "github.com/gofiber/fiber/v3" + "github.com/sonjek/go-full-stack-example/internal/service" "github.com/sonjek/go-full-stack-example/internal/web/templ/components" "github.com/sonjek/go-full-stack-example/internal/web/templ/page" "github.com/sonjek/go-full-stack-example/internal/web/templ/view" ) const ( - pageSize = 2 - timeoutMs = 300 - - maxFormBodyBytes = 1 << 20 // 1 MiB + maxTitleLen = 65 + maxBodyLen = 500 ) -func parseFormWithBodyLimit(w http.ResponseWriter, r *http.Request) error { - r.Body = http.MaxBytesReader(w, r.Body, maxFormBodyBytes) - return r.ParseForm() -} - -func respondFormParseError(w http.ResponseWriter, r *http.Request, err error) { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - w.WriteHeader(http.StatusRequestEntityTooLarge) - handleRenderError(components.ErrorMsg("Request body too large").Render(r.Context(), w)) - return +// Notes godoc +// @Summary List notes +// @Description Get paginated notes with cursor-based pagination +// @Tags notes +// @Produce html +// @Param cursor query int false "Cursor for pagination" +// @Success 200 {string} html "Notes page rendered as HTML" +// @Failure 400 {string} string "Error message" +// @Router /notes [get] +func (h *Handlers) Notes(c fiber.Ctx) error { + cursor, err := parseCursor(c, 0) + if err != nil { + return sendErrorMsg(c, "Invalid cursor parameter") } - sendErrorMsg(w, r, "Invalid form") -} -func (h *Handlers) Notes(w http.ResponseWriter, r *http.Request) { - notes, err := h.noteService.LoadMore(0, pageSize) + notes, err := h.noteService.LoadMore(cursor) if err != nil { - sendErrorMsg(w, r, "Note is empty") + slog.Error("Failed to load notes", "error", err) + return sendErrorMsg(c, "Failed to load notes") } - // Timeout for show loader - time.Sleep(timeoutMs * time.Millisecond) - - handleRenderError(page.Index(view.NotesView(notes)).Render(r.Context(), w)) + return render(c, http.StatusOK, page.Index(view.NotesView(notes), getCSRFToken(c))) } -func (h *Handlers) LoadMoreNotes(w http.ResponseWriter, r *http.Request) { - cursor := -1 - if p := r.URL.Query().Get("cursor"); p != "" { - if parsedCursor, err := strconv.Atoi(p); err == nil { - cursor = parsedCursor - } +// LoadMoreNotes godoc +// @Summary Load more notes +// @Description Load additional notes for infinite scroll (HTMX fragment) +// @Tags notes +// @Produce html +// @Param cursor query int false "Cursor for pagination" +// @Success 200 {string} html "Notes list fragment" +// @Failure 400 {string} string "Error message" +// @Router /notes/load-more [get] +func (h *Handlers) LoadMoreNotes(c fiber.Ctx) error { + cursor, err := parseCursor(c, 0) + if err != nil { + return sendErrorMsg(c, "Invalid cursor parameter") } - notes, err := h.noteService.LoadMore(cursor, pageSize) + notes, err := h.noteService.LoadMore(cursor) if err != nil { - sendErrorMsg(w, r, "Note is empty") + slog.Error("Failed to load more notes", "error", err, "cursor", cursor) + return sendErrorMsg(c, "Failed to load notes") } - // Timeout for show loader - time.Sleep(timeoutMs * time.Millisecond) - - handleRenderError(components.NotesList(notes).Render(r.Context(), w)) + return render(c, http.StatusOK, components.NotesList(notes)) } -func (h *Handlers) CreateNoteModal(w http.ResponseWriter, r *http.Request) { - handleRenderError(components.ModalAddNote().Render(r.Context(), w)) +// CreateNoteModal godoc +// @Summary Get create note modal +// @Description Returns the HTML for the create note modal dialog +// @Tags notes +// @Produce html +// @Success 200 {string} html "Modal HTML fragment" +// @Router /add [get] +func (h *Handlers) CreateNoteModal(c fiber.Ctx) error { + return render(c, http.StatusOK, components.ModalAddNote()) } -func (h *Handlers) CreateNote(w http.ResponseWriter, r *http.Request) { - if err := parseFormWithBodyLimit(w, r); err != nil { - respondFormParseError(w, r, err) - return +// CreateNote godoc +// @Summary Create a note +// @Description Create a new note from form data +// @Tags notes +// @Accept x-www-form-urlencoded +// @Produce html +// @Param title formData string true "Note title" +// @Param body formData string true "Note body content" +// @Success 200 {string} html "Created note as HTML card" +// @Failure 400 {string} string "Error message" +// @Router /api/v1/notes [post] +func (h *Handlers) CreateNote(c fiber.Ctx) error { + title := strings.TrimSpace(c.FormValue("title")) + body := strings.TrimSpace(c.FormValue("body")) + + if errs := validateNote(title, body); len(errs) > 0 { + return sendFieldErrors(c, errs) } - title := r.PostForm.Get("title") - if title == "" { - sendErrorMsg(w, r, "Title is empty") - return + note, err := h.noteService.Create(title, body) + if err != nil { + if service.IsDuplicateTitle(err) { + return sendFieldErrors(c, fieldErrors{"title": service.MsgTitleAlreadyExists}) + } + slog.Error("Failed to create note", "error", err) + return sendErrorMsg(c, "Failed to create note") } - body := r.PostForm.Get("body") - if body == "" { - sendErrorMsg(w, r, "Body is empty") - return - } + return render(c, http.StatusOK, components.NoteItem(note)) +} - note := h.noteService.Create(title, body) +// EditNoteModal godoc +// @Summary Get edit note modal +// @Description Returns the HTML for the edit note modal dialog +// @Tags notes +// @Produce html +// @Param id path int true "Note ID" +// @Success 200 {string} html "Modal HTML fragment" +// @Failure 400 {string} string "Error message" +// @Router /edit/{id} [get] +func (h *Handlers) EditNoteModal(c fiber.Ctx) error { + noteID, err := parseNoteID(c) + if err != nil { + return sendErrorMsg(c, "Invalid note ID") + } - // Timeout for show loader - time.Sleep(timeoutMs * time.Millisecond) + note, err := h.noteService.Get(noteID) + if err != nil { + if service.IsRecordNotFound(err) { + return sendErrorMsg(c, "Note not found") + } + slog.Error("Failed to get note", "error", err) + return sendErrorMsg(c, "Failed to load note") + } - handleRenderError(components.NoteItem(note).Render(r.Context(), w)) + return render(c, http.StatusOK, components.ModalEditNote(note)) } -func (h *Handlers) EditNoteModal(w http.ResponseWriter, r *http.Request) { - noteID := -1 - if p := r.PathValue("id"); p != "" { - if parsedNoteID, err := strconv.Atoi(p); err == nil { - noteID = parsedNoteID - } +// EditNote godoc +// @Summary Update a note +// @Description Update an existing note by ID +// @Tags notes +// @Accept x-www-form-urlencoded +// @Produce html +// @Param id path int true "Note ID" +// @Param title formData string true "Note title" +// @Param body formData string true "Note body content" +// @Success 200 {string} html "Updated note as HTML card" +// @Failure 400 {string} string "Error message" +// @Router /api/v1/notes/{id} [put] +func (h *Handlers) EditNote(c fiber.Ctx) error { + noteID, err := parseNoteID(c) + if err != nil { + return sendErrorMsg(c, "Invalid note ID") } - if noteID < 1 { - sendErrorMsg(w, r, "Wrong note ID") - return + title := strings.TrimSpace(c.FormValue("title")) + body := strings.TrimSpace(c.FormValue("body")) + + if errs := validateNote(title, body); len(errs) > 0 { + return sendFieldErrors(c, errs) } - note := h.noteService.Get(noteID) + note, err := h.noteService.FindAndUpdate(noteID, title, body) + if err != nil { + if service.IsRecordNotFound(err) { + return sendErrorMsg(c, "Note not found") + } + if service.IsDuplicateTitle(err) { + return sendFieldErrors(c, fieldErrors{"title": service.MsgTitleAlreadyExists}) + } + slog.Error("Failed to update note", "error", err) + return sendErrorMsg(c, "Failed to update note") + } - handleRenderError(components.ModalEditNote(note).Render(r.Context(), w)) + return render(c, http.StatusOK, components.NoteItem(note)) } -func (h *Handlers) EditNote(w http.ResponseWriter, r *http.Request) { - if err := parseFormWithBodyLimit(w, r); err != nil { - respondFormParseError(w, r, err) - return +// DeleteNote godoc +// @Summary Delete a note +// @Description Hard-delete a note by ID +// @Tags notes +// @Param id path int true "Note ID" +// @Success 200 "Note deleted successfully" +// @Failure 400 {string} string "Error message" +// @Router /api/v1/notes/{id} [delete] +func (h *Handlers) DeleteNote(c fiber.Ctx) error { + noteID, err := parseNoteID(c) + if err != nil { + return sendErrorMsg(c, "Invalid note ID") + } + + if err := h.noteService.Delete(noteID); err != nil { + if service.IsRecordNotFound(err) { + return sendErrorMsg(c, "Note not found") + } + slog.Error("Failed to delete note", "error", err) + return sendErrorMsg(c, "Failed to delete note") } - title := r.PostForm.Get("title") + return c.SendStatus(http.StatusOK) +} + +func validateNote(title, body string) fieldErrors { + errs := fieldErrors{} + if title == "" { - sendErrorMsg(w, r, "Title is empty") - return + errs["title"] = "Title is empty" + } else if len(title) > maxTitleLen { + errs["title"] = "Title is too long. Maximum " + strconv.Itoa(maxTitleLen) + " characters." } - body := r.PostForm.Get("body") if body == "" { - sendErrorMsg(w, r, "Body is empty") - return + errs["body"] = "Body is empty" + } else if len(body) > maxBodyLen { + errs["body"] = "Body is too long. Maximum " + strconv.Itoa(maxBodyLen) + " characters." } - noteID := -1 - if p := r.PathValue("id"); p != "" { - if parsedNoteID, err := strconv.Atoi(p); err == nil { - noteID = parsedNoteID + return errs +} + +func parseCursor(c fiber.Ctx, defaultVal int) (int, error) { + if p := c.Query("cursor"); p != "" { + parsed, err := strconv.Atoi(p) + if err != nil { + slog.Warn("Invalid cursor parameter", "cursor", p) + return 0, errors.New("invalid cursor parameter") } + return parsed, nil } - - note := h.noteService.FindAndUpdate(noteID, title, body) - - // Timeout for show loader - time.Sleep(timeoutMs * time.Millisecond) - - handleRenderError(components.NoteItem(note).Render(r.Context(), w)) + return defaultVal, nil } -func (h *Handlers) DeleteNote(w http.ResponseWriter, r *http.Request) { - noteID := r.PathValue("id") - if noteID == "" { - sendErrorMsg(w, r, "Note ID is empty") - return +func parseNoteID(c fiber.Ctx) (int, error) { + p := c.Params("id") + if p == "" { + return 0, errors.New("note ID is empty") } - - // Timeout for show loader - time.Sleep(timeoutMs * time.Millisecond) - - h.noteService.Delete(noteID) + noteID, err := strconv.Atoi(p) + if err != nil { + slog.Warn("Invalid note ID format", "id", p) + return 0, errors.New("invalid note ID") + } + if noteID < 1 { + slog.Warn("Note ID out of range", "id", noteID) + return 0, errors.New("invalid note ID") + } + return noteID, nil } diff --git a/internal/web/middleware/cache.go b/internal/web/middleware/cache.go deleted file mode 100644 index 6974cc4..0000000 --- a/internal/web/middleware/cache.go +++ /dev/null @@ -1,17 +0,0 @@ -package middleware - -import ( - "net/http" - "strings" -) - -// CacheStaticFiles wraps a file server handler to add cache headers for static assets (24 hours) -func CacheStaticFiles(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/static/") { - w.Header().Set("Cache-Control", "public, max-age=86400") - } - - next.ServeHTTP(w, r) - }) -} diff --git a/internal/web/middleware/logging.go b/internal/web/middleware/logging.go index f39122e..2d07bea 100644 --- a/internal/web/middleware/logging.go +++ b/internal/web/middleware/logging.go @@ -4,49 +4,27 @@ import ( "log/slog" "net/http" "time" + + "github.com/gofiber/fiber/v3" ) -type wrappedWriter struct { - http.ResponseWriter - statusCode int -} +func LoggingMiddleware(c fiber.Ctx) error { + start := time.Now() -func (w *wrappedWriter) WriteHeader(sc int) { - if w.statusCode == 0 { - w.ResponseWriter.WriteHeader(sc) - } - w.statusCode = sc -} + err := c.Next() -func (w *wrappedWriter) Write(data []byte) (int, error) { - if w.statusCode == 0 { - w.statusCode = http.StatusOK + statusCode := c.Response().StatusCode() + if statusCode == 0 { + statusCode = http.StatusOK } - return w.ResponseWriter.Write(data) -} - -func LoggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - - wrapped := &wrappedWriter{ - ResponseWriter: w, - statusCode: 0, - } - - next.ServeHTTP(wrapped, r) - statusCode := wrapped.statusCode - if statusCode == 0 { - statusCode = http.StatusOK - } + // #nosec G706 - input is sanitized via slog's internal escaping + slog.Info("Request", + "status", statusCode, + "method", c.Method(), + "path", c.Path(), + "duration", time.Since(start), + ) - // #nosec G706 - input is sanitized via slog's internal escaping - slog.Info("Request", - "status", statusCode, - "method", r.Method, - "path", r.URL.Path, - "duration", time.Since(start), - ) - }) + return err } diff --git a/internal/web/middleware/slowdown.go b/internal/web/middleware/slowdown.go deleted file mode 100644 index 1896069..0000000 --- a/internal/web/middleware/slowdown.go +++ /dev/null @@ -1,14 +0,0 @@ -package middleware - -import ( - "net/http" - "time" -) - -func SlowdownMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Slow down request by 2 milliseconds for lazy loading demonstration - time.Sleep(2 * time.Millisecond) - next.ServeHTTP(w, r) - }) -} diff --git a/internal/web/middleware/stack.go b/internal/web/middleware/stack.go deleted file mode 100644 index 154c934..0000000 --- a/internal/web/middleware/stack.go +++ /dev/null @@ -1,19 +0,0 @@ -package middleware - -import ( - "net/http" -) - -type Middleware func(http.Handler) http.Handler - -func CreateMiddlewareStack(ms ...Middleware) Middleware { - return func(next http.Handler) http.Handler { - // Call from the end of the stack to the beginning - for i := len(ms) - 1; i >= 0; i-- { - x := ms[i] - next = x(next) - } - - return next - } -} diff --git a/internal/web/monitor.go b/internal/web/monitor.go new file mode 100644 index 0000000..96f1c91 --- /dev/null +++ b/internal/web/monitor.go @@ -0,0 +1,9 @@ +package web + +import "github.com/gofiber/contrib/v3/monitor" + +func (ws *Server) setupMonitor() { + ws.app.Get("/monitor/api", monitor.New(monitor.Config{ + APIOnly: true, + })) +} diff --git a/internal/web/noswagger.go b/internal/web/noswagger.go new file mode 100644 index 0000000..a3db6d5 --- /dev/null +++ b/internal/web/noswagger.go @@ -0,0 +1,6 @@ +//go:build !swagger + +package web + +func (ws *Server) setupSwagger() { +} diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js new file mode 100644 index 0000000..6d3e3a6 --- /dev/null +++ b/internal/web/static/js/app.js @@ -0,0 +1,73 @@ +// Configure theme based on system preference +const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +const theme = localStorage.getItem("theme") || (isDark ? "dark" : "light"); +document.documentElement.setAttribute("data-theme", theme); + +// CSRF Token Configuration for htmx requests +document.addEventListener('htmx:configRequest', (event) => { + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) { + event.detail.headers['X-Csrf-Token'] = meta.content; + } +}); + +// Update CSRF Token on htmx request response +document.addEventListener('htmx:beforeOnLoad', (event) => { + const newToken = event.detail.xhr.getResponseHeader('X-Csrf-Token'); + if (newToken) { + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) meta.content = newToken; + } +}); + +window.closeDialog = () => { + // Resets dialog to a blank state to clear dialog blur effect + document.getElementById('dialog').outerHTML = '
'; +}; + +window.clearFieldErrors = () => { + document.querySelectorAll('[id^="err-"]').forEach(el => el.textContent = ''); +}; + +window.setFieldErrors = (raw) => { + window.clearFieldErrors(); + try { + const { errors } = JSON.parse(raw); + Object.entries(errors).forEach(([field, msg]) => { + const el = document.getElementById(`err-${field}`); + if (el) el.textContent = msg; + }); + } catch (e) {} +}; + +document.addEventListener('DOMContentLoaded', () => { + // Theme toggle logic + const toggle = document.getElementById('theme-toggle'); + const root = document.documentElement; + if (toggle) { + toggle.checked = root.getAttribute("data-theme") === "light"; + toggle.onchange = ({ target }) => { + const theme = target.checked ? "light" : "dark"; + root.setAttribute("data-theme", theme); + localStorage.setItem("theme", theme); + }; + } + + // htmx requests logic + if (window.htmx) { + // Clear field errors after swap + htmx.on('htmx:afterSwap', ({ detail }) => { + if (detail.target.id === 'dialog') window.clearFieldErrors?.(); + }); + + // Update visibility of "No Notes" placeholder after note operations + htmx.on('htmx:afterRequest', ({ detail: { target } }) => { + const isNoteTarget = target?.id === 'notes' || target?.id?.startsWith('note-'); + const notes = document.getElementById('notes'); + + if (isNoteTarget && notes) { + document.getElementById('no-notes')?.classList.toggle('hidden', notes.childElementCount > 0); + } + }); + } +}); diff --git a/internal/web/swagger.go b/internal/web/swagger.go new file mode 100644 index 0000000..7c2d8bc --- /dev/null +++ b/internal/web/swagger.go @@ -0,0 +1,12 @@ +//go:build swagger + +package web + +import ( + "github.com/gofiber/contrib/v3/swaggo" + _ "github.com/sonjek/go-full-stack-example/docs" +) + +func (ws *Server) setupSwagger() { + ws.app.Get("/swagger/*", swaggo.HandlerDefault) +} diff --git a/internal/web/templ/components/error.templ b/internal/web/templ/components/error.templ index f03fb17..2486397 100644 --- a/internal/web/templ/components/error.templ +++ b/internal/web/templ/components/error.templ @@ -7,13 +7,5 @@ templ ErrorMsg(msg string) { Error: { msg } - - } diff --git a/internal/web/templ/components/modal.templ b/internal/web/templ/components/modal.templ index 8d9cf20..ed3218a 100644 --- a/internal/web/templ/components/modal.templ +++ b/internal/web/templ/components/modal.templ @@ -2,7 +2,7 @@ package components templ Modal(method, url, target, swap string, content templ.Component) { -
-

+

{ note.Body }

@@ -58,7 +60,7 @@ templ NoteItem(note storage.Note, loadMoreURL ...string) {
- { datetime.FormatToDateTime(note.CreatedAt) } ({ datetime.FormatToAgo(note.CreatedAt) }) + { note.CreatedAt.Format("2006-01-02 15:04") } ({ humanize.Time(note.CreatedAt) })
{ note.ID } @@ -66,7 +68,3 @@ templ NoteItem(note storage.Note, loadMoreURL ...string) {
} - -templ LastNote(note storage.Note) { - @NoteItem(note, "/notes/load-more?cursor=" + strconv.Itoa(note.ID)) -} diff --git a/internal/web/templ/components/notesList.templ b/internal/web/templ/components/notesList.templ index 19e13ab..c7bfb93 100644 --- a/internal/web/templ/components/notesList.templ +++ b/internal/web/templ/components/notesList.templ @@ -1,12 +1,16 @@ package components -import "github.com/sonjek/go-full-stack-example/internal/storage" +import ( + "strconv" + + "github.com/sonjek/go-full-stack-example/internal/storage" +) templ NotesList(notes []storage.Note) { if len(notes) != 0 { for index, note := range notes { if (index == len(notes)-1) { - @LastNote(note) + @NoteItem(note, "/notes/load-more?cursor=" + strconv.Itoa(note.ID)) } else { @NoteItem(note) } diff --git a/internal/web/templ/page/index.templ b/internal/web/templ/page/index.templ index aab89e9..5dc1a79 100644 --- a/internal/web/templ/page/index.templ +++ b/internal/web/templ/page/index.templ @@ -4,22 +4,17 @@ import ( "github.com/sonjek/go-full-stack-example/internal/web/templ/components" ) -templ Index(body templ.Component) { +templ Index(body templ.Component, csrfToken string) { - + + Notes App diff --git a/internal/web/templ/view/notesView.templ b/internal/web/templ/view/notesView.templ index d53c23c..f997186 100644 --- a/internal/web/templ/view/notesView.templ +++ b/internal/web/templ/view/notesView.templ @@ -23,9 +23,7 @@ templ NotesView(notes []storage.Note) { New Note -
-
if len(notes) != 0 { @components.NotesList(notes) @@ -42,28 +40,4 @@ templ NotesView(notes []storage.Note) {
- - } diff --git a/internal/web/web.go b/internal/web/web.go index 42627fd..588c13b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -1,81 +1,160 @@ package web import ( + "context" "embed" - "fmt" "io/fs" - "net/http" + "os" + "strings" "time" + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/extractors" + "github.com/gofiber/fiber/v3/middleware/csrf" + "github.com/gofiber/fiber/v3/middleware/helmet" + "github.com/gofiber/fiber/v3/middleware/session" + "github.com/gofiber/fiber/v3/middleware/static" "github.com/sonjek/go-full-stack-example/internal/web/handlers" "github.com/sonjek/go-full-stack-example/internal/web/middleware" ) +const ( + defaultPort = "3000" + requestBodyLimit = 1 << 20 // 1 MiB + maxCacheAge = 86400 // 24 hours +) + +//go:embed static/* +var staticFiles embed.FS + type Server struct { - mux *http.ServeMux + app *fiber.App handlers *handlers.Handlers } func NewServer(h *handlers.Handlers) *Server { - mux := http.NewServeMux() + app := fiber.New(fiber.Config{ + BodyLimit: requestBodyLimit, + }) return &Server{ - mux: mux, + app: app, handlers: h, } } -//go:embed static/* -var staticFiles embed.FS +func (ws *Server) SetupMiddleware() { + ws.app.Use(middleware.LoggingMiddleware) -func (ws Server) Start() { - ws.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // Check if the requested URL is one of the defined handlers - // If not, redirect to the custom 404 page - _, pattern := ws.mux.Handler(r) - if r.URL.Path != pattern { - ws.handlers.Page404(w, r) - } + // Add Global Security Headers + ws.app.Use(helmet.New(helmet.Config{ + ContentTypeNosniff: "nosniff", + XFrameOptions: "SAMEORIGIN", + ReferrerPolicy: "strict-origin-when-cross-origin", + // Use CSP middleware instead + ContentSecurityPolicy: "", + })) - // Redirect from site index to notes page - http.Redirect(w, r, "/notes", http.StatusSeeOther) + ws.app.Use(func(c fiber.Ctx) error { + csp := "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self';" + c.Set("Content-Security-Policy", csp) + return c.Next() }) - ws.mux.HandleFunc("/notes", ws.handlers.Notes) - ws.mux.HandleFunc("/notes/load-more", ws.handlers.LoadMoreNotes) - ws.mux.HandleFunc("/add", ws.handlers.CreateNoteModal) - ws.mux.HandleFunc("POST /notes", ws.handlers.CreateNote) - ws.mux.HandleFunc("/edit/{id}", ws.handlers.EditNoteModal) - ws.mux.HandleFunc("PUT /note/{id}", ws.handlers.EditNote) - ws.mux.HandleFunc("DELETE /note/{id}", ws.handlers.DeleteNote) + sessionMiddleware, sessionStore := session.NewWithStore(session.Config{ + CookieHTTPOnly: true, + CookieSameSite: "Lax", + IdleTimeout: 30 * time.Minute, + }) + ws.app.Use(sessionMiddleware) + + ws.app.Use(csrf.New(csrf.Config{ + CookieName: "csrf_", + CookieHTTPOnly: true, // Server-side only cookie (no JavaScript access) + CookieSameSite: "Lax", + CookieSessionOnly: true, + SingleUseToken: false, + Extractor: extractors.FromHeader("X-Csrf-Token"), + Session: sessionStore, + })) + + // CSRF Token Refresher Middleware (for htmx requests) + ws.app.Use(func(c fiber.Ctx) error { + // Let request finish (CSRF and Handlers run) + err := c.Next() + + // No refresh if the request is not successful + if c.Response().StatusCode() >= 400 { + return err + } + + // Set CSRF token in the response header for HTML and HTMX requests only + isHTML := strings.Contains(c.Get(fiber.HeaderAccept), "text/html") + isHTMX := c.Get("HX-Request") != "" + if isHTML || isHTMX { + // Extract updated token from the context AFTER handler execution + if token := csrf.TokenFromContext(c); token != "" { + c.Set("X-Csrf-Token", token) + } + } - // Use embed.FS to create a file system from the embedded files - ws.mux.Handle("/static/", http.FileServerFS(staticFiles)) + return err + }) +} - fileSystem, err := fs.Sub(staticFiles, "static") +func (ws *Server) SetupRoutes() error { + subFS, err := fs.Sub(staticFiles, "static") if err != nil { - panic(err) - } - ws.mux.Handle("/favicon.ico", http.StripPrefix("/", http.FileServerFS(fileSystem))) - - fmt.Println("Starting web interface on port: 8089") - - // Create stack for handle multiple middlewares - middlewares := middleware.CreateMiddlewareStack( - middleware.LoggingMiddleware, - middleware.CacheStaticFiles, - middleware.SlowdownMiddleware, - ) - - server := &http.Server{ - Addr: ":8089", - Handler: middlewares(ws.mux), - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 10 * time.Second, + return err } - if err := server.ListenAndServe(); err != nil { - panic(err) + ws.app.Use("/static", static.New("", static.Config{ + FS: subFS, + MaxAge: maxCacheAge, + })) + + ws.app.Get("/favicon.ico", func(c fiber.Ctx) error { + return c.Redirect().To("/static/favicon.ico") + }) + + ws.app.Get("/", func(c fiber.Ctx) error { + return c.Redirect().To("/notes") + }) + + api := ws.app.Group("/api/v1") + api.Post("/notes", ws.handlers.CreateNote) + api.Put("/notes/:id", ws.handlers.EditNote) + api.Delete("/notes/:id", ws.handlers.DeleteNote) + + ws.app.Get("/notes", ws.handlers.Notes) + ws.app.Get("/notes/load-more", ws.handlers.LoadMoreNotes) + ws.app.Get("/add", ws.handlers.CreateNoteModal) + ws.app.Get("/edit/:id", ws.handlers.EditNoteModal) + + ws.setupSwagger() + ws.setupMonitor() + + ws.app.Get("/health", ws.handlers.Health) + + ws.app.Use(func(c fiber.Ctx) error { + return ws.handlers.Page404(c) + }) + + return nil +} + +func (ws *Server) Start() error { + port := os.Getenv("PORT") + if port == "" { + port = defaultPort } + return ws.app.Listen(":" + port) +} + +func (ws *Server) Shutdown(ctx context.Context) error { + return ws.app.ShutdownWithContext(ctx) } diff --git a/package.json b/package.json index 06e4d75..3a46d6a 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,14 @@ "description": "Frontend dependencies for the Go templ application", "private": true, "dependencies": { - "htmx.org": "^2.0.7", - "htmx-ext-response-targets": "^2.0.4", - "ionicons": "^8.0.13" + "htmx.org": "2.0.8", + "htmx-ext-response-targets": "2.0.4", + "ionicons": "8.0.13" }, "devDependencies": { - "@tailwindcss/cli": "^4.2.2", + "@tailwindcss/cli": "4.2.2", "daisyui": "5.5.19", - "tailwindcss": "^4.2.2" + "tailwindcss": "4.2.2" }, "engines": { "bun": "1.3.11" diff --git a/pkg/datetime/datetime.go b/pkg/datetime/datetime.go deleted file mode 100644 index b90098a..0000000 --- a/pkg/datetime/datetime.go +++ /dev/null @@ -1,15 +0,0 @@ -package datetime - -import ( - "time" - - "github.com/dustin/go-humanize" -) - -func FormatToAgo(datetime time.Time) string { - return humanize.Time(datetime) -} - -func FormatToDateTime(datetime time.Time) string { - return datetime.Format("2006-01-02 15:04") -} diff --git a/pkg/datetime/datetime_test.go b/pkg/datetime/datetime_test.go deleted file mode 100644 index 716ceb8..0000000 --- a/pkg/datetime/datetime_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package datetime - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestFormatToAgo(t *testing.T) { - now := time.Now() - testCases := []struct { - name string - input time.Time - expectedOutput string - }{ - { - name: "10 minutes ago", - input: now.Add(-10 * time.Minute), - expectedOutput: "10 minutes ago", - }, - { - name: "2 hours ago", - input: now.Add(-2 * time.Hour), - expectedOutput: "2 hours ago", - }, - { - name: "1 day ago", - input: now.Add(-24 * time.Hour), - expectedOutput: "1 day ago", - }, - { - name: "1 week ago", - input: now.Add(-7 * 24 * time.Hour), - expectedOutput: "1 week ago", - }, - { - name: "4 minutes from now", - input: now.Add(5 * time.Minute), - expectedOutput: "4 minutes from now", - }, - { - name: "6 days from now", - input: now.Add(7 * 24 * time.Hour), - expectedOutput: "6 days from now", - }, - } - - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - actualOutput := FormatToAgo(testCase.input) - - assert.Equal(t, testCase.expectedOutput, actualOutput) - }) - } -} - -func TestFormatToDateTime(t *testing.T) { - input := time.Date(2024, 12, 12, 12, 30, 0, 0, time.UTC) - actualOutput := FormatToDateTime(input) - - assert.Equal(t, "2024-12-12 12:30", actualOutput) -}