From dc124d0d7798a592af2e883dd93f23081e043f4e Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Sat, 22 Aug 2026 17:27:55 +0200 Subject: [PATCH] ai slop ah --- Makefile | 5 +- README.md | 21 +- backend/Dockerfile | 14 + backend/cmd/seed/main.go | 6 +- backend/cmd/server/main.go | 91 +- backend/docs/docs.go | 1828 +++++++++++++++++ backend/docs/swagger.json | 1807 ++++++++++++++++ backend/docs/swagger.yaml | 1165 +++++++++++ backend/go.mod | 46 +- backend/go.sum | 155 +- backend/internal/auth/handler.go | 191 +- backend/internal/auth/repository.go | 47 + backend/internal/auth/service.go | 117 +- backend/internal/auth/service_test.go | 21 +- backend/internal/dev/handler.go | 66 +- backend/internal/downloads/handler.go | 130 +- backend/internal/galleries/handler.go | 424 ++-- .../internal/galleries/handler_sqlite_test.go | 13 +- .../galleries/repository_sqlite_test.go | 4 +- backend/internal/gifts/handler.go | 73 - backend/internal/gifts/handler_test.go | 125 -- backend/internal/gifts/models.go | 28 - backend/internal/gifts/repository.go | 97 - .../internal/gifts/repository_sqlite_test.go | 57 - backend/internal/gifts/service.go | 33 - backend/internal/media/handler.go | 255 ++- backend/internal/storage/storage.go | 2 +- docker-compose.yml | 60 +- frontend/Dockerfile | 12 + frontend/index.html | 2 +- frontend/nginx.conf | 26 + frontend/src/App.tsx | 8 +- frontend/src/components/app/AuthLayout.tsx | 12 +- .../components/dashboard/DashboardLayout.tsx | 10 +- .../components/dashboard/UploadDropzone.tsx | 4 +- .../src/components/gallery/ClientGallery.tsx | 32 +- .../src/components/gallery/PasswordGate.tsx | 7 +- frontend/src/components/gift/Background.tsx | 21 - frontend/src/components/gift/Confetti.tsx | 36 - .../src/components/gift/GiftExperience.tsx | 217 -- frontend/src/components/gift/GiftItem.tsx | 24 - frontend/src/components/gift/ImageItem.tsx | 36 - frontend/src/components/gift/IntroScreen.tsx | 90 - .../src/components/gift/ProgressIndicator.tsx | 22 - frontend/src/components/gift/RevealScreen.tsx | 90 - frontend/src/components/gift/TextItem.tsx | 25 - frontend/src/components/gift/VideoItem.tsx | 31 - frontend/src/lib/api.ts | 41 +- frontend/src/pages/GiftPage.tsx | 88 - frontend/src/pages/LoginPage.tsx | 18 +- frontend/src/pages/NotFoundPage.tsx | 8 +- frontend/src/pages/PublicGalleryPage.tsx | 8 +- frontend/src/pages/RegisterPage.tsx | 20 +- .../src/pages/dashboard/DashboardPage.tsx | 36 +- frontend/src/pages/dashboard/DevPage.tsx | 12 +- .../src/pages/dashboard/GalleriesPage.tsx | 20 +- .../src/pages/dashboard/GalleryEditorPage.tsx | 36 +- frontend/src/pages/dashboard/SettingsPage.tsx | 192 ++ frontend/src/styles/index.css | 1429 ++++++++----- frontend/src/types/gift.ts | 20 - migrations/001_create_gifts.sql | 31 - migrations/002_seed_demo.sql | 91 - migrations/sqlite/001_create_gifts.sql | 29 - migrations/sqlite/002_seed_demo.sql | 91 - 64 files changed, 7308 insertions(+), 2448 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/docs/docs.go create mode 100644 backend/docs/swagger.json create mode 100644 backend/docs/swagger.yaml delete mode 100644 backend/internal/gifts/handler.go delete mode 100644 backend/internal/gifts/handler_test.go delete mode 100644 backend/internal/gifts/models.go delete mode 100644 backend/internal/gifts/repository.go delete mode 100644 backend/internal/gifts/repository_sqlite_test.go delete mode 100644 backend/internal/gifts/service.go create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf delete mode 100644 frontend/src/components/gift/Background.tsx delete mode 100644 frontend/src/components/gift/Confetti.tsx delete mode 100644 frontend/src/components/gift/GiftExperience.tsx delete mode 100644 frontend/src/components/gift/GiftItem.tsx delete mode 100644 frontend/src/components/gift/ImageItem.tsx delete mode 100644 frontend/src/components/gift/IntroScreen.tsx delete mode 100644 frontend/src/components/gift/ProgressIndicator.tsx delete mode 100644 frontend/src/components/gift/RevealScreen.tsx delete mode 100644 frontend/src/components/gift/TextItem.tsx delete mode 100644 frontend/src/components/gift/VideoItem.tsx delete mode 100644 frontend/src/pages/GiftPage.tsx create mode 100644 frontend/src/pages/dashboard/SettingsPage.tsx delete mode 100644 frontend/src/types/gift.ts delete mode 100644 migrations/001_create_gifts.sql delete mode 100644 migrations/002_seed_demo.sql delete mode 100644 migrations/sqlite/001_create_gifts.sql delete mode 100644 migrations/sqlite/002_seed_demo.sql diff --git a/Makefile b/Makefile index b73f312..eb9bfe3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev frontend-dev migrate seed test build lint format compose-up compose-down +.PHONY: dev frontend-dev migrate seed swagger test build lint format compose-up compose-down dev: go -C backend run ./cmd/server @@ -12,6 +12,9 @@ migrate: seed: go -C backend run ./cmd/seed +swagger: + go -C backend run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g cmd/server/main.go -o docs --parseInternal + test: go -C backend test ./... diff --git a/README.md b/README.md index f770b5a..fb23e65 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Northline Delivery Studio +# Noah Bianchi Studio -Northline is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop: +Noah Bianchi is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop: ```text Upload -> Curate -> Customize -> Publish -> Send a private link @@ -11,7 +11,7 @@ Photographers get a focused studio dashboard. Clients get a private editorial ga ## Stack - Frontend: React, TypeScript, Vite, React Router, Tailwind CSS, Framer Motion -- Backend: Go 1.24+, `net/http`, REST/JSON, `database/sql` +- Backend: Go 1.24+, Gin, REST/JSON, `database/sql` - Database: PostgreSQL by default; SQLite remains available for lightweight local testing - Storage: MinIO locally through an S3-compatible storage interface - Media: direct browser uploads with presigned URLs, asynchronous preview processing, signed download URLs @@ -52,6 +52,7 @@ Open: - Dashboard: [http://localhost:5173/login](http://localhost:5173/login) - Dev diagnostics: [http://localhost:5173/dashboard/dev](http://localhost:5173/dashboard/dev) - Demo client gallery: [http://localhost:5173/g/emma-james-wedding](http://localhost:5173/g/emma-james-wedding) +- API documentation: [http://localhost:8080/swagger/index.html](http://localhost:8080/swagger/index.html) - MinIO console: [http://localhost:9001](http://localhost:9001) ### PowerShell @@ -78,7 +79,7 @@ The seed command creates: ```text Email: demo@example.com Password: DemoPassword123! -Studio: Northline Studio +Studio: Noah Bianchi ``` It also creates the published demo gallery: @@ -187,6 +188,8 @@ POST /api/auth/register POST /api/auth/login POST /api/auth/logout GET /api/auth/me +PATCH /api/auth/me +POST /api/auth/password ``` Authenticated galleries: @@ -232,6 +235,12 @@ Health: GET /health ``` +Swagger UI is available at `/swagger/index.html`; the generated Swagger document is also served at `/swagger/doc.json`. Regenerate the checked-in documentation after changing API annotations with: + +```bash +make swagger +``` + Photographer routes use an HTTP-only signed session cookie. Public visitor identity and gallery access are separate signed cookies; clients do not need accounts. ## Database @@ -256,7 +265,7 @@ The schema does not store uploaded media content. `external_url` exists only to ```text backend/ - cmd/server/ API and worker startup + cmd/server/ Gin API router and worker startup cmd/migrate/ dialect-aware SQL migration runner cmd/seed/ demo photographer and gallery seed internal/auth/ bcrypt auth, signed sessions, visitor access cookies @@ -281,6 +290,8 @@ Preview and public delivery use the same `ClientGallery` component. The preview Backend: ```bash +make test +make lint ``` Frontend: diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..af59bac --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.24-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /usr/bin/server ./cmd/server +RUN go build -o /usr/bin/migrate ./cmd/migrate +RUN go build -o /usr/bin/seed ./cmd/seed + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +COPY --from=builder /usr/bin/server /usr/bin/migrate /usr/bin/seed /usr/bin/ +EXPOSE 8080 +CMD ["server"] \ No newline at end of file diff --git a/backend/cmd/seed/main.go b/backend/cmd/seed/main.go index 1b524b1..5f80b10 100644 --- a/backend/cmd/seed/main.go +++ b/backend/cmd/seed/main.go @@ -29,7 +29,7 @@ func main() { if err := seed(ctx, database); err != nil { log.Fatal(err) } - log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail) + log.Printf("seeded %s with the Noah Bianchi demo gallery", demoEmail) } func seed(ctx context.Context, database *sql.DB) error { @@ -41,7 +41,7 @@ func seed(ctx context.Context, database *sql.DB) error { INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4) ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP - `, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil { + `, demoUserID, demoEmail, string(passwordHash), "Noah Bianchi"); err != nil { return fmt.Errorf("seed demo user: %w", err) } @@ -62,7 +62,7 @@ func seed(ctx context.Context, database *sql.DB) error { updated_at = CURRENT_TIMESTAMP `, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID, `{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`, - `{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil { + `{"studioName":"Noah Bianchi","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil { return fmt.Errorf("seed demo gallery: %w", err) } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c779e8a..b6e7036 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,17 +10,29 @@ import ( "syscall" "time" + _ "github.com/example/sndit/backend/docs" "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/config" "github.com/example/sndit/backend/internal/db" devtools "github.com/example/sndit/backend/internal/dev" "github.com/example/sndit/backend/internal/downloads" "github.com/example/sndit/backend/internal/galleries" - "github.com/example/sndit/backend/internal/gifts" "github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/storage" + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" ) +// @title Noah Bianchi Studio API +// @version 1.0 +// @description REST API for photographer galleries and client delivery. +// @schemes http https +// @BasePath / +// @securityDefinitions.apikey studioSession +// @in header +// @name Cookie +// @description Use the studio_session cookie as studio_session=. func main() { cfg := config.Load() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -73,23 +85,25 @@ func main() { CookieSecure: cfg.CookieSecure, }) - mux := http.NewServeMux() - mux.HandleFunc("GET /health", health) - authHandler.RegisterRoutes(mux) - galleryHandler.RegisterProtectedRoutes(mux, authService.Require) - mediaHandler.RegisterRoutes(mux, authService.Require) - galleryHandler.RegisterPublicRoutes(mux) - downloadHandler.RegisterRoutes(mux) - devHandler.RegisterRoutes(mux, authService.Require) - - // Keep the original public gift endpoint available while the gallery product - // uses /api/public/galleries/:slug. - legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database))) - mux.Handle("/api/gifts/", legacyGifts.Routes()) + router := gin.New() + router.Use(gin.Recovery(), withCORS(cfg.CORSOrigin), withLogging()) + router.GET("/health", health) + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, + ginSwagger.DocExpansion("none"), + ginSwagger.PersistAuthorization(true), + )) + require := authService.Require() + authHandler.RegisterRoutes(router) + authHandler.RegisterProtectedRoutes(router, require) + galleryHandler.RegisterProtectedRoutes(router, require) + mediaHandler.RegisterRoutes(router, require) + galleryHandler.RegisterPublicRoutes(router) + downloadHandler.RegisterRoutes(router) + devHandler.RegisterRoutes(router, require) server := &http.Server{ Addr: ":" + cfg.Port, - Handler: withCORS(withLogging(mux), cfg.CORSOrigin), + Handler: router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, @@ -111,28 +125,33 @@ func main() { } } -func health(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"ok"}` + "\n")) +// health godoc +// @Summary Check API health +// @Tags system +// @Produce json +// @Success 200 {object} map[string]string +// @Router /health [get] +func health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) } -func withCORS(next http.Handler, allowedOrigin string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := r.Header.Get("Origin") +func withCORS(allowedOrigin string) gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") if origin != "" && originAllowed(origin, allowedOrigin) { - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Vary", "Origin") + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Vary", "Origin") } - if r.Method == http.MethodOptions { - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.WriteHeader(http.StatusNoContent) + if c.Request.Method == http.MethodOptions { + c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type") + c.Status(http.StatusNoContent) + c.Abort() return } - next.ServeHTTP(w, r) - }) + c.Next() + } } func originAllowed(origin, configured string) bool { @@ -144,10 +163,10 @@ func originAllowed(origin, configured string) bool { return false } -func withLogging(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func withLogging() gin.HandlerFunc { + return func(c *gin.Context) { started := time.Now() - next.ServeHTTP(w, r) - log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond)) - }) + c.Next() + log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started).Round(time.Millisecond)) + } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..1e42186 --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,1828 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Sign in a photographer", + "parameters": [ + { + "description": "Account credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.credentialsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Sign out the current photographer", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Get the current photographer", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Update the current photographer profile", + "parameters": [ + { + "description": "Profile details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.profileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/password": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Change the current photographer password", + "parameters": [ + { + "description": "Password details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.passwordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Register a photographer account", + "parameters": [ + { + "description": "Account details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.credentialsRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/dev/diagnostics": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "development" + ], + "summary": "Inspect local development dependencies", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/dev/storage-check": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "development" + ], + "summary": "Check MinIO storage read/write access", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/galleries": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "List owned galleries", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Create a gallery", + "parameters": [ + { + "description": "Gallery details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/galleries.createRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Get an owned gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Delete a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Update a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Gallery changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/media": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "List gallery media", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/preview": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Preview a gallery as a client", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/publish": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Publish a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/unpublish": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Unpublish a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/uploads": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Create a presigned media upload", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Upload metadata", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/media.uploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/media/{id}": { + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Delete media or cancel an upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Update media ordering", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Media changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/media.updateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/media/{id}/download": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Create a signed original media download URL", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Get a published public gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/authenticate": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Authenticate to a password-protected gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "Gallery password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/galleries.publicPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/download-all": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Start a public gallery ZIP download", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/download-all/{jobId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Get public gallery ZIP download status", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Download job UUID", + "name": "jobId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/media/{mediaId}/download": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Download one public gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/media/{mediaId}/favorite": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Favorite a gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Remove a favorite from a gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/uploads/{id}": { + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Delete media or cancel an upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/uploads/{id}/complete": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Complete a media upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Check API health", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "auth.credentialsRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "auth.passwordRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "auth.profileRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "galleries.createRequest": { + "type": "object", + "properties": { + "clientName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "galleries.publicPasswordRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "media.updateRequest": { + "type": "object", + "properties": { + "sortOrder": { + "type": "integer" + } + } + }, + "media.uploadRequest": { + "type": "object", + "properties": { + "fileSize": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "studioSession": { + "description": "Use the studio_session cookie as studio_session=\u003cvalue\u003e.", + "type": "apiKey", + "name": "Cookie", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "", + BasePath: "/", + Schemes: []string{"http", "https"}, + Title: "Noah Bianchi Studio API", + Description: "REST API for photographer galleries and client delivery.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 0000000..6042b81 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,1807 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "REST API for photographer galleries and client delivery.", + "title": "Noah Bianchi Studio API", + "contact": {}, + "version": "1.0" + }, + "basePath": "/", + "paths": { + "/api/auth/login": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Sign in a photographer", + "parameters": [ + { + "description": "Account credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.credentialsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Sign out the current photographer", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Get the current photographer", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Update the current photographer profile", + "parameters": [ + { + "description": "Profile details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.profileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/password": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Change the current photographer password", + "parameters": [ + { + "description": "Password details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.passwordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "authentication" + ], + "summary": "Register a photographer account", + "parameters": [ + { + "description": "Account details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.credentialsRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/dev/diagnostics": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "development" + ], + "summary": "Inspect local development dependencies", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/dev/storage-check": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "development" + ], + "summary": "Check MinIO storage read/write access", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/galleries": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "List owned galleries", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Create a gallery", + "parameters": [ + { + "description": "Gallery details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/galleries.createRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Get an owned gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Delete a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Update a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Gallery changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "additionalProperties": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/media": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "List gallery media", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/preview": { + "get": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Preview a gallery as a client", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/publish": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Publish a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/unpublish": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "galleries" + ], + "summary": "Unpublish a gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/galleries/{id}/uploads": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Create a presigned media upload", + "parameters": [ + { + "type": "string", + "description": "Gallery UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Upload metadata", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/media.uploadRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/media/{id}": { + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Delete media or cancel an upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "patch": { + "security": [ + { + "studioSession": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Update media ordering", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Media changes", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/media.updateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/media/{id}/download": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Create a signed original media download URL", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Get a published public gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/authenticate": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Authenticate to a password-protected gallery", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "description": "Gallery password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/galleries.publicPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/download-all": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Start a public gallery ZIP download", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "202": { + "description": "Accepted", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/download-all/{jobId}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Get public gallery ZIP download status", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Download job UUID", + "name": "jobId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/media/{mediaId}/download": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public downloads" + ], + "summary": "Download one public gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/public/galleries/{slug}/media/{mediaId}/favorite": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Favorite a gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "public galleries" + ], + "summary": "Remove a favorite from a gallery media item", + "parameters": [ + { + "type": "string", + "description": "Gallery slug", + "name": "slug", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Media UUID", + "name": "mediaId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/uploads/{id}": { + "delete": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Delete media or cancel an upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/uploads/{id}/complete": { + "post": { + "security": [ + { + "studioSession": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "media" + ], + "summary": "Complete a media upload", + "parameters": [ + { + "type": "string", + "description": "Media UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "system" + ], + "summary": "Check API health", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "auth.credentialsRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "auth.passwordRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "auth.profileRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "galleries.createRequest": { + "type": "object", + "properties": { + "clientName": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "galleries.publicPasswordRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + } + } + }, + "media.updateRequest": { + "type": "object", + "properties": { + "sortOrder": { + "type": "integer" + } + } + }, + "media.uploadRequest": { + "type": "object", + "properties": { + "fileSize": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "studioSession": { + "description": "Use the studio_session cookie as studio_session=\u003cvalue\u003e.", + "type": "apiKey", + "name": "Cookie", + "in": "header" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 0000000..19d6339 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,1165 @@ +basePath: / +definitions: + auth.credentialsRequest: + properties: + email: + type: string + name: + type: string + password: + type: string + type: object + auth.passwordRequest: + properties: + currentPassword: + type: string + newPassword: + type: string + type: object + auth.profileRequest: + properties: + email: + type: string + name: + type: string + type: object + galleries.createRequest: + properties: + clientName: + type: string + description: + type: string + title: + type: string + type: object + galleries.publicPasswordRequest: + properties: + password: + type: string + type: object + media.updateRequest: + properties: + sortOrder: + type: integer + type: object + media.uploadRequest: + properties: + fileSize: + type: integer + filename: + type: string + mimeType: + type: string + type: object +info: + contact: {} + description: REST API for photographer galleries and client delivery. + title: Noah Bianchi Studio API + version: "1.0" +paths: + /api/auth/login: + post: + consumes: + - application/json + parameters: + - description: Account credentials + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.credentialsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Sign in a photographer + tags: + - authentication + /api/auth/logout: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Sign out the current photographer + tags: + - authentication + /api/auth/me: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Get the current photographer + tags: + - authentication + patch: + consumes: + - application/json + parameters: + - description: Profile details + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.profileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Update the current photographer profile + tags: + - authentication + /api/auth/password: + post: + consumes: + - application/json + parameters: + - description: Password details + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.passwordRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Change the current photographer password + tags: + - authentication + /api/auth/register: + post: + consumes: + - application/json + parameters: + - description: Account details + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.credentialsRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + summary: Register a photographer account + tags: + - authentication + /api/dev/diagnostics: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Inspect local development dependencies + tags: + - development + /api/dev/storage-check: + post: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: true + type: object + security: + - studioSession: [] + summary: Check MinIO storage read/write access + tags: + - development + /api/galleries: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: List owned galleries + tags: + - galleries + post: + consumes: + - application/json + parameters: + - description: Gallery details + in: body + name: request + required: true + schema: + $ref: '#/definitions/galleries.createRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Create a gallery + tags: + - galleries + /api/galleries/{id}: + delete: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Delete a gallery + tags: + - galleries + get: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Get an owned gallery + tags: + - galleries + patch: + consumes: + - application/json + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + - description: Gallery changes + in: body + name: request + required: true + schema: + additionalProperties: true + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Update a gallery + tags: + - galleries + /api/galleries/{id}/media: + get: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: List gallery media + tags: + - media + /api/galleries/{id}/preview: + get: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Preview a gallery as a client + tags: + - galleries + /api/galleries/{id}/publish: + post: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Publish a gallery + tags: + - galleries + /api/galleries/{id}/unpublish: + post: + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Unpublish a gallery + tags: + - galleries + /api/galleries/{id}/uploads: + post: + consumes: + - application/json + parameters: + - description: Gallery UUID + in: path + name: id + required: true + type: string + - description: Upload metadata + in: body + name: request + required: true + schema: + $ref: '#/definitions/media.uploadRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Create a presigned media upload + tags: + - media + /api/media/{id}: + delete: + parameters: + - description: Media UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Delete media or cancel an upload + tags: + - media + patch: + consumes: + - application/json + parameters: + - description: Media UUID + in: path + name: id + required: true + type: string + - description: Media changes + in: body + name: request + required: true + schema: + $ref: '#/definitions/media.updateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Update media ordering + tags: + - media + /api/media/{id}/download: + post: + parameters: + - description: Media UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Create a signed original media download URL + tags: + - media + /api/public/galleries/{slug}: + get: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get a published public gallery + tags: + - public galleries + /api/public/galleries/{slug}/authenticate: + post: + consumes: + - application/json + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + - description: Gallery password + in: body + name: request + required: true + schema: + $ref: '#/definitions/galleries.publicPasswordRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Authenticate to a password-protected gallery + tags: + - public galleries + /api/public/galleries/{slug}/download-all: + post: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "202": + description: Accepted + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Start a public gallery ZIP download + tags: + - public downloads + /api/public/galleries/{slug}/download-all/{jobId}: + get: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + - description: Download job UUID + in: path + name: jobId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get public gallery ZIP download status + tags: + - public downloads + /api/public/galleries/{slug}/media/{mediaId}/download: + post: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + - description: Media UUID + in: path + name: mediaId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Download one public gallery media item + tags: + - public downloads + /api/public/galleries/{slug}/media/{mediaId}/favorite: + delete: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + - description: Media UUID + in: path + name: mediaId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: boolean + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Remove a favorite from a gallery media item + tags: + - public galleries + post: + parameters: + - description: Gallery slug + in: path + name: slug + required: true + type: string + - description: Media UUID + in: path + name: mediaId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: boolean + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Favorite a gallery media item + tags: + - public galleries + /api/uploads/{id}: + delete: + parameters: + - description: Media UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Delete media or cancel an upload + tags: + - media + /api/uploads/{id}/complete: + post: + parameters: + - description: Media UUID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - studioSession: [] + summary: Complete a media upload + tags: + - media + /health: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Check API health + tags: + - system +schemes: +- http +- https +securityDefinitions: + studioSession: + description: Use the studio_session cookie as studio_session=. + in: header + name: Cookie + type: apiKey +swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod index 0e2d1ff..33d79e2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,36 +3,74 @@ module github.com/example/sndit/backend go 1.24.0 require ( + github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.6 github.com/joho/godotenv v1.5.1 github.com/minio/minio-go/v7 v7.0.95 - golang.org/x/crypto v0.39.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + golang.org/x/crypto v0.41.0 modernc.org/sqlite v1.39.1 ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ini/ini v1.67.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/net v0.41.0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.36.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index d8b7c6b..0dbf039 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,12 +1,56 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -21,11 +65,28 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= @@ -34,42 +95,116 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU= github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= 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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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 v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +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/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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.2/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 2fafaf4..4027395 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -5,6 +5,8 @@ import ( "errors" "net/http" "strings" + + "github.com/gin-gonic/gin" ) type Handler struct { @@ -15,11 +17,16 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/auth/register", h.Register) - mux.HandleFunc("POST /api/auth/login", h.Login) - mux.HandleFunc("POST /api/auth/logout", h.Logout) - mux.HandleFunc("GET /api/auth/me", h.Me) +func (h *Handler) RegisterRoutes(router gin.IRouter) { + router.POST("/api/auth/register", h.Register) + router.POST("/api/auth/login", h.Login) + router.POST("/api/auth/logout", h.Logout) + router.GET("/api/auth/me", h.Me) +} + +func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) { + router.PATCH("/api/auth/me", require, h.UpdateMe) + router.POST("/api/auth/password", require, h.ChangePassword) } type credentialsRequest struct { @@ -28,66 +35,188 @@ type credentialsRequest struct { Name string `json:"name"` } -func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { +type profileRequest struct { + Email string `json:"email"` + Name string `json:"name"` +} + +type passwordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +// Register godoc +// @Summary Register a photographer account +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body credentialsRequest true "Account details" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/auth/register [post] +func (h *Handler) Register(c *gin.Context) { var request credentialsRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } - user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name) + user, err := h.service.Register(c.Request.Context(), request.Email, request.Password, request.Name) if err != nil { if errors.Is(err, ErrEmailTaken) { - writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"}) + writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"}) return } - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } - h.service.SetSession(w, user) - writeJSON(w, http.StatusCreated, map[string]User{"user": user}) + h.service.SetSession(c, user) + writeJSON(c, http.StatusCreated, map[string]User{"user": user}) } -func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { +// Login godoc +// @Summary Sign in a photographer +// @Tags authentication +// @Accept json +// @Produce json +// @Param request body credentialsRequest true "Account credentials" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/auth/login [post] +func (h *Handler) Login(c *gin.Context) { var request credentialsRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } - user, err := h.service.Login(r.Context(), request.Email, request.Password) + user, err := h.service.Login(c.Request.Context(), request.Email, request.Password) if err != nil { if errors.Is(err, ErrInvalidCredentials) { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"}) + writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"}) return } - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"}) + writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not sign in"}) return } - h.service.SetSession(w, user) - writeJSON(w, http.StatusOK, map[string]User{"user": user}) + h.service.SetSession(c, user) + writeJSON(c, http.StatusOK, map[string]User{"user": user}) } -func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) { - h.service.ClearSession(w) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +// Logout godoc +// @Summary Sign out the current photographer +// @Tags authentication +// @Produce json +// @Success 200 {object} map[string]string +// @Router /api/auth/logout [post] +func (h *Handler) Logout(c *gin.Context) { + h.service.ClearSession(c) + writeJSON(c, http.StatusOK, map[string]string{"status": "ok"}) } -func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { - user, err := h.service.UserFromRequest(r.Context(), r) +// Me godoc +// @Summary Get the current photographer +// @Tags authentication +// @Produce json +// @Security studioSession +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Router /api/auth/me [get] +func (h *Handler) Me(c *gin.Context) { + user, err := h.service.UserFromRequest(c.Request.Context(), c.Request) if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) return } - writeJSON(w, http.StatusOK, map[string]User{"user": user}) + writeJSON(c, http.StatusOK, map[string]User{"user": user}) } -func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { - writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"}) +// UpdateMe godoc +// @Summary Update the current photographer profile +// @Tags authentication +// @Accept json +// @Produce json +// @Security studioSession +// @Param request body profileRequest true "Profile details" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /api/auth/me [patch] +func (h *Handler) UpdateMe(c *gin.Context) { + user, ok := UserFromContext(c) + if !ok { + writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + var request profileRequest + if !decodeJSON(c, &request) { + return + } + updated, err := h.service.UpdateProfile(c.Request.Context(), user.ID, request.Email, request.Name) + if err != nil { + if errors.Is(err, ErrEmailTaken) { + writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"}) + return + } + if strings.HasPrefix(err.Error(), "enter ") || strings.HasPrefix(err.Error(), "name ") { + writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update profile"}) + return + } + writeJSON(c, http.StatusOK, map[string]User{"user": updated}) +} + +// ChangePassword godoc +// @Summary Change the current photographer password +// @Tags authentication +// @Accept json +// @Produce json +// @Security studioSession +// @Param request body passwordRequest true "Password details" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/auth/password [post] +func (h *Handler) ChangePassword(c *gin.Context) { + user, ok := UserFromContext(c) + if !ok { + writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + var request passwordRequest + if !decodeJSON(c, &request) { + return + } + if err := h.service.ChangePassword(c.Request.Context(), user.ID, request.CurrentPassword, request.NewPassword); err != nil { + if errors.Is(err, ErrCurrentPassword) { + writeJSON(c, http.StatusBadRequest, map[string]string{"error": "current password is incorrect"}) + return + } + if strings.HasPrefix(err.Error(), "new password") { + writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update password"}) + return + } + writeJSON(c, http.StatusOK, map[string]string{"status": "ok"}) +} + +func decodeJSON(c *gin.Context, target any) bool { + if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { + writeJSON(c, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"}) return false } - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) + writeJSON(c, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"}) return false } return true } + +func writeJSON(c *gin.Context, status int, value any) { + c.JSON(status, value) +} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 1927b31..b05338d 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -66,3 +66,50 @@ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) { } return user, nil } + +func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (storedUser, error) { + var user storedUser + err := r.db.QueryRowContext(ctx, ` + SELECT id, email, name, password_hash + FROM users + WHERE id = $1 + `, id).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) + if errors.Is(err, sql.ErrNoRows) { + return storedUser{}, sql.ErrNoRows + } + if err != nil { + return storedUser{}, fmt.Errorf("find user credentials: %w", err) + } + return user, nil +} + +func (r *Repository) UpdateUser(ctx context.Context, id uuid.UUID, email, name string) (User, error) { + _, err := r.db.ExecContext(ctx, ` + UPDATE users + SET email = $1, name = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + `, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), id) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return User{}, ErrEmailTaken + } + return User{}, fmt.Errorf("update user: %w", err) + } + return r.FindByID(ctx, id) +} + +func (r *Repository) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error { + result, err := r.db.ExecContext(ctx, ` + UPDATE users + SET password_hash = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + `, passwordHash, id) + if err != nil { + return fmt.Errorf("update password: %w", err) + } + count, err := result.RowsAffected() + if err != nil || count == 0 { + return sql.ErrNoRows + } + return nil +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index 3b97826..11ca0f2 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -29,6 +30,7 @@ const ( var ( ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidSession = errors.New("invalid session") + ErrCurrentPassword = errors.New("current password is incorrect") ) type Service struct { @@ -78,33 +80,47 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro return user.User, nil } -func (s *Service) SetSession(w http.ResponseWriter, user User) { +func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, name string) (User, error) { + email = strings.ToLower(strings.TrimSpace(email)) + name = strings.TrimSpace(name) + if !strings.Contains(email, "@") || len(email) > 254 { + return User{}, fmt.Errorf("enter a valid email address") + } + if name == "" || len(name) > 120 { + return User{}, fmt.Errorf("name is required") + } + return s.repository.UpdateUser(ctx, userID, email, name) +} + +func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { + if len(newPassword) < 8 { + return fmt.Errorf("new password must be at least 8 characters") + } + user, err := s.repository.FindByIDWithPassword(ctx, userID) + if err != nil { + return err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil { + return ErrCurrentPassword + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash new password: %w", err) + } + return s.repository.UpdatePassword(ctx, userID, string(hash)) +} + +func (s *Service) SetSession(c *gin.Context, user User) { payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()} token, err := s.signJSON(payload) if err != nil { return } - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: token, - Path: "/", - MaxAge: int(sessionDuration.Seconds()), - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - }) + s.setCookie(c, sessionCookieName, token, int(sessionDuration.Seconds()), true) } -func (s *Service) ClearSession(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - }) +func (s *Service) ClearSession(c *gin.Context) { + s.setCookie(c, sessionCookieName, "", -1, true) } func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) { @@ -131,61 +147,47 @@ type contextKey string const userContextKey contextKey = "authenticated-user" -func (s *Service) Require(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, err := s.UserFromRequest(r.Context(), r) +func (s *Service) Require() gin.HandlerFunc { + return func(c *gin.Context) { + user, err := s.UserFromRequest(c.Request.Context(), c.Request) if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + c.AbortWithStatusJSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"}) return } - next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user))) - }) + c.Set(userContextKey, user) + c.Next() + } } -func UserFromContext(ctx context.Context) (User, bool) { - user, ok := ctx.Value(userContextKey).(User) - return user, ok +func UserFromContext(c *gin.Context) (User, bool) { + value, ok := c.Get(userContextKey) + user, valid := value.(User) + return user, ok && valid } -func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string { - if cookie, err := r.Cookie(visitorCookieName); err == nil { +func (s *Service) EnsureVisitor(c *gin.Context) string { + if cookie, err := c.Request.Cookie(visitorCookieName); err == nil { if _, err := uuid.Parse(cookie.Value); err == nil { return cookie.Value } } visitorID := uuid.NewString() - http.SetCookie(w, &http.Cookie{ - Name: visitorCookieName, - Value: visitorID, - Path: "/", - MaxAge: int(365 * 24 * time.Hour / time.Second), - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - }) + s.setCookie(c, visitorCookieName, visitorID, int(365*24*time.Hour/time.Second), true) return visitorID } -func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) { +func (s *Service) GrantGalleryAccess(c *gin.Context, slug string) { payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()} token, err := s.signJSON(payload) if err != nil { return } - http.SetCookie(w, &http.Cookie{ - Name: accessCookieName, - Value: token, - Path: "/", - MaxAge: int(accessDuration.Seconds()), - HttpOnly: true, - Secure: s.secure, - SameSite: http.SameSiteLaxMode, - }) + s.setCookie(c, accessCookieName, token, int(accessDuration.Seconds()), true) } -func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool { - cookie, err := r.Cookie(accessCookieName) +func (s *Service) HasGalleryAccess(c *gin.Context, slug string) bool { + cookie, err := c.Request.Cookie(accessCookieName) if err != nil { return false } @@ -196,6 +198,11 @@ func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool { return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix() } +func (s *Service) setCookie(c *gin.Context, name, value string, maxAge int, httpOnly bool) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie(name, value, maxAge, "/", "", s.secure, httpOnly) +} + type sessionPayload struct { UserID string `json:"userId"` ExpiresAt int64 `json:"expiresAt"` @@ -235,9 +242,3 @@ func (s *Service) signature(value string) string { _, _ = hash.Write([]byte(value)) return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) } - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go index 27213f2..bd5fbad 100644 --- a/backend/internal/auth/service_test.go +++ b/backend/internal/auth/service_test.go @@ -2,11 +2,13 @@ package auth import ( "context" + "errors" "net/http" "net/http/httptest" "testing" appdb "github.com/example/sndit/backend/internal/db" + "github.com/gin-gonic/gin" ) func TestRegisterLoginAndSession(t *testing.T) { @@ -33,7 +35,7 @@ func TestRegisterLoginAndSession(t *testing.T) { if err != nil { t.Fatalf("create auth service: %v", err) } - user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio") + user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Noah Bianchi") if err != nil { t.Fatalf("register user: %v", err) } @@ -44,9 +46,24 @@ func TestRegisterLoginAndSession(t *testing.T) { if err != nil || loggedIn.ID != user.ID { t.Fatalf("login failed: user=%+v err=%v", loggedIn, err) } + updated, err := service.UpdateProfile(ctx, user.ID, "updated@example.com", "Updated Studio") + if err != nil || updated.Email != "updated@example.com" || updated.Name != "Updated Studio" { + t.Fatalf("profile update failed: user=%+v err=%v", updated, err) + } + if err := service.ChangePassword(ctx, user.ID, "DemoPassword123!", "NewPassword123!"); err != nil { + t.Fatalf("password update failed: %v", err) + } + if _, err := service.Login(ctx, "updated@example.com", "DemoPassword123!"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("old password should be invalid, got %v", err) + } + if _, err := service.Login(ctx, "updated@example.com", "NewPassword123!"); err != nil { + t.Fatalf("new password should work: %v", err) + } recorder := httptest.NewRecorder() - service.SetSession(recorder, user) + ginContext, _ := gin.CreateTestContext(recorder) + ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) + service.SetSession(ginContext, user) request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil) request.AddCookie(recorder.Result().Cookies()[0]) fromSession, err := service.UserFromRequest(ctx, request) diff --git a/backend/internal/dev/handler.go b/backend/internal/dev/handler.go index 0574821..beeb8e8 100644 --- a/backend/internal/dev/handler.go +++ b/backend/internal/dev/handler.go @@ -5,7 +5,6 @@ import ( "crypto/rand" "database/sql" "encoding/hex" - "encoding/json" "fmt" "net/http" "strings" @@ -13,6 +12,7 @@ import ( "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/storage" + "github.com/gin-gonic/gin" ) type Config struct { @@ -35,28 +35,36 @@ func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Ser return &Handler{db: db, storage: objectStorage, auth: authService, config: config} } -func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { - mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics))) - mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck))) +func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) { + router.GET("/api/dev/diagnostics", require, h.Diagnostics) + router.POST("/api/dev/storage-check", require, h.StorageCheck) } -func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) { - user, _ := auth.UserFromContext(r.Context()) +// Diagnostics godoc +// @Summary Inspect local development dependencies +// @Tags development +// @Produce json +// @Security studioSession +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Router /api/dev/diagnostics [get] +func (h *Handler) Diagnostics(c *gin.Context) { + user, _ := auth.UserFromContext(c) databaseError := "" - databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second) + databaseContext, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) if err := h.db.PingContext(databaseContext); err != nil { databaseError = err.Error() } cancel() storageError := "" - storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second) + storageContext, storageCancel := context.WithTimeout(c.Request.Context(), 3*time.Second) if err := h.storage.EnsureBucket(storageContext); err != nil { storageError = err.Error() } storageCancel() - writeJSON(w, http.StatusOK, map[string]any{ + writeJSON(c, http.StatusOK, map[string]any{ "environment": "development", "now": time.Now().UTC().Format(time.RFC3339), "user": map[string]string{ @@ -84,33 +92,43 @@ func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) { }) } -func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) { - if err := h.storage.EnsureBucket(r.Context()); err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()}) +// StorageCheck godoc +// @Summary Check MinIO storage read/write access +// @Tags development +// @Produce json +// @Security studioSession +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]interface{} +// @Failure 503 {object} map[string]interface{} +// @Router /api/dev/storage-check [post] +func (h *Handler) StorageCheck(c *gin.Context) { + if err := h.storage.EnsureBucket(c.Request.Context()); err != nil { + writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()}) return } var randomBytes [12]byte if _, err := rand.Read(randomBytes[:]); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()}) + writeJSON(c, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()}) return } key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:])) - contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano) - if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()}) + contents := "studio storage check " + time.Now().UTC().Format(time.RFC3339Nano) + if err := h.storage.Put(c.Request.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil { + writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()}) return } - info, statErr := h.storage.Stat(r.Context(), key) - deleteErr := h.storage.Delete(r.Context(), key) + info, statErr := h.storage.Stat(c.Request.Context(), key) + deleteErr := h.storage.Delete(c.Request.Context(), key) if statErr != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()}) + writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()}) return } if deleteErr != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()}) + writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()}) return } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType}) + writeJSON(c, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType}) } func splitOrigins(value string) []string { @@ -123,8 +141,6 @@ func splitOrigins(value string) []string { return result } -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) +func writeJSON(c *gin.Context, status int, value any) { + c.JSON(status, value) } diff --git a/backend/internal/downloads/handler.go b/backend/internal/downloads/handler.go index 3301b7f..0a52d8c 100644 --- a/backend/internal/downloads/handler.go +++ b/backend/internal/downloads/handler.go @@ -1,7 +1,6 @@ package downloads import ( - "encoding/json" "net/http" "strings" "time" @@ -10,6 +9,7 @@ import ( "github.com/example/sndit/backend/internal/galleries" "github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/storage" + "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -25,80 +25,114 @@ func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media. return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service} } -func (h *Handler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download) - mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll) - mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus) +func (h *Handler) RegisterRoutes(router gin.IRouter) { + router.POST("/api/public/galleries/:slug/media/:mediaId/download", h.Download) + router.POST("/api/public/galleries/:slug/download-all", h.DownloadAll) + router.GET("/api/public/galleries/:slug/download-all/:jobId", h.DownloadAllStatus) } -func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { - record, err := h.publicRecord(r) +// Download godoc +// @Summary Download one public gallery media item +// @Tags public downloads +// @Produce json +// @Param slug path string true "Gallery slug" +// @Param mediaId path string true "Media UUID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/public/galleries/{slug}/media/{mediaId}/download [post] +func (h *Handler) Download(c *gin.Context) { + record, err := h.publicRecord(c) if err != nil { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } if !record.DownloadsEnabled { - writeError(w, http.StatusForbidden, "downloads are disabled") + writeError(c, http.StatusForbidden, "downloads are disabled") return } - mediaID, err := uuid.Parse(r.PathValue("mediaId")) + mediaID, err := uuid.Parse(c.Param("mediaId")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } - item, err := h.media.GetByID(r.Context(), mediaID) + item, err := h.media.GetByID(c.Request.Context(), mediaID) if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady { - writeError(w, http.StatusNotFound, "media not found") + writeError(c, http.StatusNotFound, "media not found") return } - url, err := h.downloadURL(r, item) + url, err := h.downloadURL(c, item) if err != nil { - writeError(w, http.StatusInternalServerError, "could not create download") + writeError(c, http.StatusInternalServerError, "could not create download") return } - visitorID := h.auth.EnsureVisitor(w, r) - _ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID) - writeJSON(w, http.StatusOK, map[string]string{"url": url}) + visitorID := h.auth.EnsureVisitor(c) + _ = h.media.RecordDownload(c.Request.Context(), record.ID, &mediaID, visitorID) + writeJSON(c, http.StatusOK, map[string]string{"url": url}) } -func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) { - record, err := h.publicRecord(r) +// DownloadAll godoc +// @Summary Start a public gallery ZIP download +// @Tags public downloads +// @Produce json +// @Param slug path string true "Gallery slug" +// @Success 202 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/public/galleries/{slug}/download-all [post] +func (h *Handler) DownloadAll(c *gin.Context) { + record, err := h.publicRecord(c) if err != nil { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } if !record.DownloadAllEnabled || !record.DownloadsEnabled { - writeError(w, http.StatusForbidden, "gallery downloads are disabled") + writeError(c, http.StatusForbidden, "gallery downloads are disabled") return } - visitorID := h.auth.EnsureVisitor(w, r) - job, err := h.service.Create(r.Context(), record.ID, visitorID) + visitorID := h.auth.EnsureVisitor(c) + job, err := h.service.Create(c.Request.Context(), record.ID, visitorID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not start gallery download") + writeError(c, http.StatusInternalServerError, "could not start gallery download") return } - writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status}) + writeJSON(c, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status}) } -func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) { - record, err := h.publicRecord(r) +// DownloadAllStatus godoc +// @Summary Get public gallery ZIP download status +// @Tags public downloads +// @Produce json +// @Param slug path string true "Gallery slug" +// @Param jobId path string true "Download job UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/public/galleries/{slug}/download-all/{jobId} [get] +func (h *Handler) DownloadAllStatus(c *gin.Context) { + record, err := h.publicRecord(c) if err != nil { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } if !record.DownloadAllEnabled || !record.DownloadsEnabled { - writeError(w, http.StatusForbidden, "gallery downloads are disabled") + writeError(c, http.StatusForbidden, "gallery downloads are disabled") return } - jobID, err := uuid.Parse(r.PathValue("jobId")) + jobID, err := uuid.Parse(c.Param("jobId")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid download job id") + writeError(c, http.StatusBadRequest, "invalid download job id") return } - visitorID := h.auth.EnsureVisitor(w, r) - job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID) + visitorID := h.auth.EnsureVisitor(c) + job, err := h.service.Get(c.Request.Context(), jobID, record.ID, visitorID) if err != nil { - writeError(w, http.StatusNotFound, "download job not found") + writeError(c, http.StatusNotFound, "download job not found") return } response := map[string]any{"jobId": job.ID.String(), "status": job.Status} @@ -106,40 +140,38 @@ func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) { response["error"] = job.Error } if job.Status == StatusReady { - url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour) + url, err := h.storage.CreateDownloadURL(c.Request.Context(), job.StorageKey, time.Hour) if err != nil { - writeError(w, http.StatusInternalServerError, "could not create download URL") + writeError(c, http.StatusInternalServerError, "could not create download URL") return } response["url"] = url } - writeJSON(w, http.StatusOK, response) + writeJSON(c, http.StatusOK, response) } -func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) { - record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) +func (h *Handler) publicRecord(c *gin.Context) (galleries.GalleryRecord, error) { + record, err := h.galleries.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug"))) if err != nil || record.IsExpired() { return galleries.GalleryRecord{}, galleries.ErrNotFound } - if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) { return galleries.GalleryRecord{}, galleries.ErrNotFound } return record, nil } -func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) { +func (h *Handler) downloadURL(c *gin.Context, item media.Record) (string, error) { if item.ExternalURL != "" { return item.ExternalURL, nil } - return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour) + return h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour) } -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) +func writeError(c *gin.Context, status int, message string) { + writeJSON(c, status, map[string]string{"error": message}) } -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) +func writeJSON(c *gin.Context, status int, value any) { + c.JSON(status, value) } diff --git a/backend/internal/galleries/handler.go b/backend/internal/galleries/handler.go index 268441b..7bfdc7d 100644 --- a/backend/internal/galleries/handler.go +++ b/backend/internal/galleries/handler.go @@ -12,6 +12,7 @@ import ( "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/storage" + "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -34,22 +35,22 @@ func NewHandler(repository *Repository, mediaRepository *media.Repository, objec } } -func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { - mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List))) - mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create))) - mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get))) - mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update))) - mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete))) - mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish))) - mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish))) - mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview))) +func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) { + router.GET("/api/galleries", require, h.List) + router.POST("/api/galleries", require, h.Create) + router.GET("/api/galleries/:id", require, h.Get) + router.PATCH("/api/galleries/:id", require, h.Update) + router.DELETE("/api/galleries/:id", require, h.Delete) + router.POST("/api/galleries/:id/publish", require, h.Publish) + router.POST("/api/galleries/:id/unpublish", require, h.Unpublish) + router.GET("/api/galleries/:id/preview", require, h.Preview) } -func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public) - mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic) - mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite) - mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite) +func (h *Handler) RegisterPublicRoutes(router gin.IRouter) { + router.GET("/api/public/galleries/:slug", h.Public) + router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic) + router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite) + router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite) } type createRequest struct { @@ -78,15 +79,23 @@ type publicPasswordRequest struct { Password string `json:"password"` } -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// List godoc +// @Summary List owned galleries +// @Tags galleries +// @Produce json +// @Security studioSession +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Router /api/galleries [get] +func (h *Handler) List(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - summaries, err := h.repository.ListForUser(r.Context(), user.ID) + summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load galleries") + writeError(c, http.StatusInternalServerError, "could not load galleries") return } for index := range summaries { @@ -97,97 +106,141 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) { if err != nil { continue } - cover, err := h.media.GetForUser(r.Context(), user.ID, coverID) + cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID) if err != nil { continue } - summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false) + summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false) } - writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries}) + writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries}) } -func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Create godoc +// @Summary Create a gallery +// @Tags galleries +// @Accept json +// @Produce json +// @Security studioSession +// @Param request body createRequest true "Gallery details" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/galleries [post] +func (h *Handler) Create(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } var request createRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } request.Title = strings.TrimSpace(request.Title) request.ClientName = strings.TrimSpace(request.ClientName) if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 { - writeError(w, http.StatusBadRequest, "title and client name are required") + writeError(c, http.StatusBadRequest, "title and client name are required") return } - record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description)) + record, err := h.repository.Create(c.Request.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description)) if err != nil { - writeError(w, http.StatusInternalServerError, "could not create gallery") + writeError(c, http.StatusInternalServerError, "could not create gallery") return } - writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)}) + writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)}) } -func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Get godoc +// @Summary Get an owned gallery +// @Tags galleries +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id} [get] +func (h *Handler) Get(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - record, err := h.recordForUser(r, user.ID) + record, err := h.recordForUser(c, user.ID) if err != nil { - writeGalleryError(w, err) + writeGalleryError(c, err) return } - items, err := h.media.ListByGallery(r.Context(), record.ID) + items, err := h.media.ListByGallery(c.Request.Context(), record.ID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load gallery media") + writeError(c, http.StatusInternalServerError, "could not load gallery media") return } - views, err := h.mediaViews(r.Context(), items, "", true) + views, err := h.mediaViews(c.Request.Context(), items, "", true) if err != nil { - writeError(w, http.StatusInternalServerError, "could not sign media URLs") + writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } - writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) + writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) } -func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Preview godoc +// @Summary Preview a gallery as a client +// @Tags galleries +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id}/preview [get] +func (h *Handler) Preview(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - record, err := h.recordForUser(r, user.ID) + record, err := h.recordForUser(c, user.ID) if err != nil { - writeGalleryError(w, err) + writeGalleryError(c, err) return } - gallery, err := h.publicPayload(r.Context(), r, record, true, true, "") + gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "") if err != nil { - writeError(w, http.StatusInternalServerError, "could not build gallery preview") + writeError(c, http.StatusInternalServerError, "could not build gallery preview") return } gallery.Preview = true - writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery}) + writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery}) } -func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Update godoc +// @Summary Update a gallery +// @Tags galleries +// @Accept json +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Param request body map[string]interface{} true "Gallery changes" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id} [patch] +func (h *Handler) Update(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - current, err := h.recordForUser(r, user.ID) + current, err := h.recordForUser(c, user.ID) if err != nil { - writeGalleryError(w, err) + writeGalleryError(c, err) return } var request updateRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } @@ -217,19 +270,19 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { input.Description = strings.TrimSpace(*request.Description) } if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 { - writeError(w, http.StatusBadRequest, "title and client name are required") + writeError(c, http.StatusBadRequest, "title and client name are required") return } if request.Password != nil { if strings.TrimSpace(*request.Password) == "" { input.ClearPassword = true } else if len(*request.Password) < 4 { - writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters") + writeError(c, http.StatusBadRequest, "gallery password must be at least 4 characters") return } else { hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) if err != nil { - writeError(w, http.StatusInternalServerError, "could not secure gallery password") + writeError(c, http.StatusInternalServerError, "could not secure gallery password") return } hashed := string(hash) @@ -257,7 +310,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { value := strings.TrimSpace(*request.ExpiresAt) if value != "" { if _, err := time.Parse(time.RFC3339, value); err != nil { - writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp") + writeError(c, http.StatusBadRequest, "expiry must be an ISO timestamp") return } } @@ -268,12 +321,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { if value != "" { coverID, err := uuid.Parse(value) if err != nil { - writeError(w, http.StatusBadRequest, "invalid cover media id") + writeError(c, http.StatusBadRequest, "invalid cover media id") return } - cover, err := h.media.GetForUser(r.Context(), user.ID, coverID) + cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID) if err != nil || cover.GalleryID != current.ID { - writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery") + writeError(c, http.StatusBadRequest, "cover media does not belong to this gallery") return } } @@ -281,182 +334,255 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } if len(request.ThemeConfig) > 0 { if !json.Valid(request.ThemeConfig) { - writeError(w, http.StatusBadRequest, "theme config must be valid JSON") + writeError(c, http.StatusBadRequest, "theme config must be valid JSON") return } input.ThemeConfig = request.ThemeConfig } if len(request.BrandingConfig) > 0 { if !json.Valid(request.BrandingConfig) { - writeError(w, http.StatusBadRequest, "branding config must be valid JSON") + writeError(c, http.StatusBadRequest, "branding config must be valid JSON") return } input.BrandingConfig = request.BrandingConfig } - record, err := h.repository.Update(r.Context(), user.ID, current.ID, input) + record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input) if err != nil { - writeError(w, http.StatusInternalServerError, "could not update gallery") + writeError(c, http.StatusInternalServerError, "could not update gallery") return } - items, err := h.media.ListByGallery(r.Context(), record.ID) + items, err := h.media.ListByGallery(c.Request.Context(), record.ID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load gallery media") + writeError(c, http.StatusInternalServerError, "could not load gallery media") return } - views, err := h.mediaViews(r.Context(), items, "", true) + views, err := h.mediaViews(c.Request.Context(), items, "", true) if err != nil { - writeError(w, http.StatusInternalServerError, "could not sign media URLs") + writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } - writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) + writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)}) } -func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) { - h.setStatus(w, r, StatusPublished) +// Publish godoc +// @Summary Publish a gallery +// @Tags galleries +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id}/publish [post] +func (h *Handler) Publish(c *gin.Context) { + h.setStatus(c, StatusPublished) } -func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) { - h.setStatus(w, r, StatusDraft) +// Unpublish godoc +// @Summary Unpublish a gallery +// @Tags galleries +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id}/unpublish [post] +func (h *Handler) Unpublish(c *gin.Context) { + h.setStatus(c, StatusDraft) } -func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) { - user, ok := auth.UserFromContext(r.Context()) +func (h *Handler) setStatus(c *gin.Context, status string) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - record, err := h.recordForUser(r, user.ID) + record, err := h.recordForUser(c, user.ID) if err != nil { - writeGalleryError(w, err) + writeGalleryError(c, err) return } - record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status) + record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status) if err != nil { - writeError(w, http.StatusInternalServerError, "could not update gallery status") + writeError(c, http.StatusInternalServerError, "could not update gallery status") return } - writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)}) + writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)}) } -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Delete godoc +// @Summary Delete a gallery +// @Tags galleries +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 204 +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id} [delete] +func (h *Handler) Delete(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - id, err := pathUUID(r, "id") + id, err := pathUUID(c, "id") if err != nil { - writeGalleryError(w, err) + writeGalleryError(c, err) return } - if err := h.repository.Delete(r.Context(), user.ID, id); err != nil { - writeGalleryError(w, err) + if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil { + writeGalleryError(c, err) return } - w.WriteHeader(http.StatusNoContent) + c.Status(http.StatusNoContent) } -func (h *Handler) Public(w http.ResponseWriter, r *http.Request) { - slug := strings.TrimSpace(r.PathValue("slug")) - record, err := h.repository.GetPublicBySlug(r.Context(), slug) +// Public godoc +// @Summary Get a published public gallery +// @Tags public galleries +// @Produce json +// @Param slug path string true "Gallery slug" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/public/galleries/{slug} [get] +func (h *Handler) Public(c *gin.Context) { + slug := strings.TrimSpace(c.Param("slug")) + record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug) if err != nil || record.IsExpired() { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } - if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) { gallery := h.lockedPayload(record) - writeJSON(w, http.StatusOK, gallery) + writeJSON(c, http.StatusOK, gallery) return } visitorID := "" if record.FavoritesEnabled { - visitorID = h.auth.EnsureVisitor(w, r) + visitorID = h.auth.EnsureVisitor(c) } - gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID) + gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load gallery") + writeError(c, http.StatusInternalServerError, "could not load gallery") return } - writeJSON(w, http.StatusOK, gallery) + writeJSON(c, http.StatusOK, gallery) } -func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) { - slug := strings.TrimSpace(r.PathValue("slug")) - record, err := h.repository.GetPublicBySlug(r.Context(), slug) +// AuthenticatePublic godoc +// @Summary Authenticate to a password-protected gallery +// @Tags public galleries +// @Accept json +// @Produce json +// @Param slug path string true "Gallery slug" +// @Param request body publicPasswordRequest true "Gallery password" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/public/galleries/{slug}/authenticate [post] +func (h *Handler) AuthenticatePublic(c *gin.Context) { + slug := strings.TrimSpace(c.Param("slug")) + record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug) if err != nil || record.IsExpired() { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } var request publicPasswordRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil { - writeError(w, http.StatusUnauthorized, "incorrect gallery password") + writeError(c, http.StatusUnauthorized, "incorrect gallery password") return } - h.auth.GrantGalleryAccess(w, record.Slug) + h.auth.GrantGalleryAccess(c, record.Slug) visitorID := "" if record.FavoritesEnabled { - visitorID = h.auth.EnsureVisitor(w, r) + visitorID = h.auth.EnsureVisitor(c) } - gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID) + gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load gallery") + writeError(c, http.StatusInternalServerError, "could not load gallery") return } - writeJSON(w, http.StatusOK, gallery) + writeJSON(c, http.StatusOK, gallery) } -func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) { - h.setFavorite(w, r, true) +// Favorite godoc +// @Summary Favorite a gallery media item +// @Tags public galleries +// @Produce json +// @Param slug path string true "Gallery slug" +// @Param mediaId path string true "Media UUID" +// @Success 200 {object} map[string]bool +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [post] +func (h *Handler) Favorite(c *gin.Context) { + h.setFavorite(c, true) } -func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) { - h.setFavorite(w, r, false) +// Unfavorite godoc +// @Summary Remove a favorite from a gallery media item +// @Tags public galleries +// @Produce json +// @Param slug path string true "Gallery slug" +// @Param mediaId path string true "Media UUID" +// @Success 200 {object} map[string]bool +// @Failure 400 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [delete] +func (h *Handler) Unfavorite(c *gin.Context) { + h.setFavorite(c, false) } -func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) { - record, err := h.publicRecordForRequest(r) +func (h *Handler) setFavorite(c *gin.Context, favorited bool) { + record, err := h.publicRecordForRequest(c) if err != nil { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } if !record.FavoritesEnabled { - writeError(w, http.StatusForbidden, "favorites are disabled") + writeError(c, http.StatusForbidden, "favorites are disabled") return } - mediaID, err := pathUUID(r, "mediaId") + mediaID, err := pathUUID(c, "mediaId") if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } - item, err := h.media.GetByID(r.Context(), mediaID) + item, err := h.media.GetByID(c.Request.Context(), mediaID) if err != nil || item.GalleryID != record.ID { - writeError(w, http.StatusNotFound, "media not found") + writeError(c, http.StatusNotFound, "media not found") return } - visitorID := h.auth.EnsureVisitor(w, r) - if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil { - writeError(w, http.StatusInternalServerError, "could not update favorite") + visitorID := h.auth.EnsureVisitor(c) + if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil { + writeError(c, http.StatusInternalServerError, "could not update favorite") return } - writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited}) + writeJSON(c, http.StatusOK, map[string]bool{"favorited": favorited}) } -func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) { - record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) +func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) { + record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug"))) if err != nil || record.IsExpired() { return GalleryRecord{}, ErrNotFound } - if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { + if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) { return GalleryRecord{}, ErrNotFound } return record, nil } -func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) { +func (h *Handler) publicPayload(ctx context.Context, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) { items, err := h.media.ListByGallery(ctx, record.ID) if err != nil { return Public{}, err @@ -509,12 +635,12 @@ func (h *Handler) lockedPayload(record GalleryRecord) map[string]any { } } -func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) { - id, err := pathUUID(r, "id") +func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) { + id, err := pathUUID(c, "id") if err != nil { return GalleryRecord{}, err } - return h.repository.GetForUser(r.Context(), userID, id) + return h.repository.GetForUser(c.Request.Context(), userID, id) } func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail { @@ -628,8 +754,8 @@ func newSlug(title string) string { return slug + "-" + suffix } -func pathUUID(r *http.Request, name string) (uuid.UUID, error) { - id, err := uuid.Parse(r.PathValue(name)) +func pathUUID(c *gin.Context, name string) (uuid.UUID, error) { + id, err := uuid.Parse(c.Param(name)) if err != nil { return uuid.Nil, fmt.Errorf("invalid %s", name) } @@ -644,34 +770,32 @@ func stringPointer(value string) *string { return © } -func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { - writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") +func decodeJSON(c *gin.Context, target any) bool { + if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { + writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json") return false } - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { - writeError(w, http.StatusBadRequest, "invalid JSON body") + writeError(c, http.StatusBadRequest, "invalid JSON body") return false } return true } -func writeGalleryError(w http.ResponseWriter, err error) { +func writeGalleryError(c *gin.Context, err error) { if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } - writeError(w, http.StatusInternalServerError, "could not load gallery") + writeError(c, http.StatusInternalServerError, "could not load gallery") } -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) +func writeError(c *gin.Context, status int, message string) { + writeJSON(c, status, map[string]string{"error": message}) } -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) +func writeJSON(c *gin.Context, status int, value any) { + c.JSON(status, value) } diff --git a/backend/internal/galleries/handler_sqlite_test.go b/backend/internal/galleries/handler_sqlite_test.go index c5804cc..cfbb49b 100644 --- a/backend/internal/galleries/handler_sqlite_test.go +++ b/backend/internal/galleries/handler_sqlite_test.go @@ -13,6 +13,7 @@ import ( "github.com/example/sndit/backend/internal/auth" appdb "github.com/example/sndit/backend/internal/db" "github.com/example/sndit/backend/internal/media" + "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -40,13 +41,13 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) { userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666") mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777") - if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil { + if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil { t.Fatalf("insert user: %v", err) } if _, err := database.ExecContext(ctx, ` INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config) VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8) - `, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil { + `, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Noah Bianchi"}`); err != nil { t.Fatalf("insert gallery: %v", err) } if _, err := database.ExecContext(ctx, ` @@ -61,12 +62,12 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) { t.Fatalf("create auth service: %v", err) } handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService) - mux := http.NewServeMux() - handler.RegisterPublicRoutes(mux) + router := gin.New() + handler.RegisterPublicRoutes(router) getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil) getRecorder := httptest.NewRecorder() - mux.ServeHTTP(getRecorder, getRequest) + router.ServeHTTP(getRecorder, getRequest) if getRecorder.Code != http.StatusOK { t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String()) } @@ -83,7 +84,7 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) { favoriteRequest.AddCookie(cookie) } favoriteRecorder := httptest.NewRecorder() - mux.ServeHTTP(favoriteRecorder, favoriteRequest) + router.ServeHTTP(favoriteRecorder, favoriteRequest) if favoriteRecorder.Code != http.StatusOK { t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String()) } diff --git a/backend/internal/galleries/repository_sqlite_test.go b/backend/internal/galleries/repository_sqlite_test.go index 48ee3ac..6b6cb10 100644 --- a/backend/internal/galleries/repository_sqlite_test.go +++ b/backend/internal/galleries/repository_sqlite_test.go @@ -34,7 +34,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) { } userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") - if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil { + if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil { t.Fatalf("insert user: %v", err) } repository := NewRepository(database) @@ -62,7 +62,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) { FavoritesEnabled: true, DownloadAllEnabled: true, ThemeConfig: json.RawMessage(`{"mode":"dark"}`), - BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`), + BrandingConfig: json.RawMessage(`{"studioName":"Noah Bianchi"}`), }) if err != nil { t.Fatalf("update gallery: %v", err) diff --git a/backend/internal/gifts/handler.go b/backend/internal/gifts/handler.go deleted file mode 100644 index 3cfe3fd..0000000 --- a/backend/internal/gifts/handler.go +++ /dev/null @@ -1,73 +0,0 @@ -package gifts - -import ( - "encoding/json" - "errors" - "net/http" - "strings" -) - -type Handler struct { - service *Service -} - -func NewHandler(service *Service) *Handler { - return &Handler{service: service} -} - -func (h *Handler) Routes() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /health", h.Health) - mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift) - return mux -} - -func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) -} - -func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) { - slug := strings.TrimSpace(r.PathValue("slug")) - if !validSlug(slug) { - writeError(w, http.StatusBadRequest, "invalid gift slug") - return - } - - gift, err := h.service.GetPublicGift(r.Context(), slug) - if err != nil { - if errors.Is(err, ErrNotFound) { - writeError(w, http.StatusNotFound, "gift not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load gift") - return - } - - writeJSON(w, http.StatusOK, gift) -} - -func validSlug(slug string) bool { - if len(slug) == 0 || len(slug) > 100 { - return false - } - for index, character := range slug { - if (character >= 'a' && character <= 'z') || - (character >= 'A' && character <= 'Z') || - (character >= '0' && character <= '9') || - (character == '-' && index > 0 && index < len(slug)-1) { - continue - } - return false - } - return true -} - -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} diff --git a/backend/internal/gifts/handler_test.go b/backend/internal/gifts/handler_test.go deleted file mode 100644 index 13582db..0000000 --- a/backend/internal/gifts/handler_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package gifts - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "testing" - - "github.com/google/uuid" -) - -type fakeStore struct { - gift PublicGift - err error -} - -func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) { - return f.gift, f.err -} - -func TestGetGiftReturnsPublicRepresentation(t *testing.T) { - giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111") - itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222") - service := NewService(fakeStore{gift: PublicGift{ - ID: giftID, - Slug: "demo", - RecipientName: "Anna", - SenderName: "Alex", - Title: "A little surprise for you", - IntroMessage: "I made something for you.", - RevealMessage: "A beautiful final note.", - Items: []PublicGiftItem{{ - ID: itemID, - Type: "text", - Title: "A note", - Text: "Hello", - SortOrder: 1, - }}, - }}) - - request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil) - recorder := httptest.NewRecorder() - NewHandler(service).Routes().ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", recorder.Code) - } - if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { - t.Fatalf("unexpected content type: %q", contentType) - } - - var response PublicGift - if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { - t.Fatalf("decode response: %v", err) - } - if response.RecipientName != "Anna" || len(response.Items) != 1 { - t.Fatalf("unexpected response: %+v", response) - } -} - -func TestGetGiftReturnsNotFound(t *testing.T) { - service := NewService(fakeStore{err: ErrNotFound}) - request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil) - recorder := httptest.NewRecorder() - - NewHandler(service).Routes().ServeHTTP(recorder, request) - - if recorder.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", recorder.Code) - } - if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" { - t.Fatalf("unexpected error body: %q", body) - } -} - -func TestGetGiftHidesStoreErrors(t *testing.T) { - service := NewService(fakeStore{err: errors.New("database connection lost")}) - request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil) - recorder := httptest.NewRecorder() - - NewHandler(service).Routes().ServeHTTP(recorder, request) - - if recorder.Code != http.StatusInternalServerError { - t.Fatalf("expected 500, got %d", recorder.Code) - } - if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" { - t.Fatalf("unexpected error body: %q", body) - } -} - -func TestGetGiftRejectsInvalidSlug(t *testing.T) { - service := NewService(fakeStore{}) - request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil) - recorder := httptest.NewRecorder() - - NewHandler(service).Routes().ServeHTTP(recorder, request) - - if recorder.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", recorder.Code) - } -} - -func TestHealthReturnsOK(t *testing.T) { - recorder := httptest.NewRecorder() - request := httptest.NewRequest(http.MethodGet, "/health", nil) - - NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request) - - if recorder.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", recorder.Code) - } - if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" { - t.Fatalf("unexpected health body: %q", body) - } -} - -func TestServiceRejectsEmptySlug(t *testing.T) { - service := NewService(fakeStore{}) - _, err := service.GetPublicGift(context.Background(), "") - if err != ErrNotFound { - t.Fatalf("expected ErrNotFound, got %v", err) - } -} diff --git a/backend/internal/gifts/models.go b/backend/internal/gifts/models.go deleted file mode 100644 index c8d07b4..0000000 --- a/backend/internal/gifts/models.go +++ /dev/null @@ -1,28 +0,0 @@ -package gifts - -import ( - "encoding/json" - - "github.com/google/uuid" -) - -type PublicGift struct { - ID uuid.UUID `json:"id"` - Slug string `json:"slug"` - RecipientName string `json:"recipientName"` - SenderName string `json:"senderName"` - Title string `json:"title"` - IntroMessage string `json:"introMessage"` - RevealMessage string `json:"revealMessage"` - Items []PublicGiftItem `json:"items"` -} - -type PublicGiftItem struct { - ID uuid.UUID `json:"id"` - Type string `json:"type"` - Title string `json:"title,omitempty"` - Text string `json:"text,omitempty"` - MediaURL string `json:"mediaUrl,omitempty"` - SortOrder int `json:"sortOrder"` - Metadata json.RawMessage `json:"metadata,omitempty"` -} diff --git a/backend/internal/gifts/repository.go b/backend/internal/gifts/repository.go deleted file mode 100644 index 52879ad..0000000 --- a/backend/internal/gifts/repository.go +++ /dev/null @@ -1,97 +0,0 @@ -package gifts - -import ( - "context" - "database/sql" - "errors" - "fmt" -) - -type Repository struct { - db *sql.DB -} - -func NewRepository(db *sql.DB) *Repository { - return &Repository{db: db} -} - -func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) { - var gift PublicGift - err := r.db.QueryRowContext(ctx, ` - SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message - FROM gifts - WHERE slug = $1 AND status = 'published' - `, slug).Scan( - &gift.ID, - &gift.Slug, - &gift.RecipientName, - &gift.SenderName, - &gift.Title, - &gift.IntroMessage, - &gift.RevealMessage, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return PublicGift{}, ErrNotFound - } - return PublicGift{}, fmt.Errorf("find gift: %w", err) - } - - rows, err := r.db.QueryContext(ctx, ` - SELECT id, type, title, text, media_url, sort_order, metadata - FROM gift_items - WHERE gift_id = $1 - ORDER BY sort_order ASC, id ASC - `, gift.ID) - if err != nil { - return PublicGift{}, fmt.Errorf("find gift items: %w", err) - } - defer rows.Close() - - gift.Items = make([]PublicGiftItem, 0) - for rows.Next() { - var ( - item PublicGiftItem - title sql.NullString - text sql.NullString - mediaURL sql.NullString - metadata []byte - ) - - if err := rows.Scan( - &item.ID, - &item.Type, - &title, - &text, - &mediaURL, - &item.SortOrder, - &metadata, - ); err != nil { - return PublicGift{}, fmt.Errorf("scan gift item: %w", err) - } - - item.Title = title.String - item.Text = text.String - item.MediaURL = mediaURL.String - if len(metadata) == 0 { - item.Metadata = []byte(`{}`) - } else { - item.Metadata = metadata - } - gift.Items = append(gift.Items, item) - } - - if err := rows.Err(); err != nil { - return PublicGift{}, fmt.Errorf("iterate gift items: %w", err) - } - - return gift, nil -} - -// Store is the read contract used by the service. Keeping it small makes the -// HTTP layer straightforward to test without a running database. -type Store interface { - GetPublicBySlug(context.Context, string) (PublicGift, error) -} - -var _ Store = (*Repository)(nil) diff --git a/backend/internal/gifts/repository_sqlite_test.go b/backend/internal/gifts/repository_sqlite_test.go deleted file mode 100644 index 352e276..0000000 --- a/backend/internal/gifts/repository_sqlite_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package gifts - -import ( - "context" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "testing" - - appdb "github.com/example/sndit/backend/internal/db" -) - -func TestRepositoryReadsSQLiteMigrations(t *testing.T) { - ctx := context.Background() - database, err := appdb.New(ctx, "sqlite", ":memory:") - if err != nil { - t.Fatalf("open sqlite database: %v", err) - } - defer database.Close() - - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("find test source file") - } - migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite") - entries, err := os.ReadDir(migrationDirectory) - if err != nil { - t.Fatalf("read sqlite migrations: %v", err) - } - sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) - - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { - continue - } - migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name())) - if err != nil { - t.Fatalf("read migration %s: %v", entry.Name(), err) - } - if _, err := database.ExecContext(ctx, string(migration)); err != nil { - t.Fatalf("apply migration %s: %v", entry.Name(), err) - } - } - - gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo") - if err != nil { - t.Fatalf("load demo gift: %v", err) - } - if gift.RecipientName != "Anna" || gift.SenderName != "Alex" { - t.Fatalf("unexpected gift: %+v", gift) - } - if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 { - t.Fatalf("unexpected gift items: %+v", gift.Items) - } -} diff --git a/backend/internal/gifts/service.go b/backend/internal/gifts/service.go deleted file mode 100644 index 74fdca5..0000000 --- a/backend/internal/gifts/service.go +++ /dev/null @@ -1,33 +0,0 @@ -package gifts - -import ( - "context" - "errors" - "fmt" -) - -var ErrNotFound = errors.New("gift not found") - -type Service struct { - store Store -} - -func NewService(store Store) *Service { - return &Service{store: store} -} - -func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) { - if slug == "" { - return PublicGift{}, ErrNotFound - } - - gift, err := s.store.GetPublicBySlug(ctx, slug) - if err != nil { - if errors.Is(err, ErrNotFound) { - return PublicGift{}, ErrNotFound - } - return PublicGift{}, fmt.Errorf("get public gift: %w", err) - } - - return gift, nil -} diff --git a/backend/internal/media/handler.go b/backend/internal/media/handler.go index 2cb1f76..aaff618 100644 --- a/backend/internal/media/handler.go +++ b/backend/internal/media/handler.go @@ -12,6 +12,7 @@ import ( "github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/storage" + "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -35,14 +36,14 @@ func NewHandler(repository *Repository, objectStorage storage.Storage, processor return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService} } -func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { - mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List))) - mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload))) - mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload))) - mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update))) - mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download))) - mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete))) - mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete))) +func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) { + router.GET("/api/galleries/:id/media", require, h.List) + router.POST("/api/galleries/:id/uploads", require, h.CreateUpload) + router.POST("/api/uploads/:id/complete", require, h.CompleteUpload) + router.PATCH("/api/media/:id", require, h.Update) + router.POST("/api/media/:id/download", require, h.Download) + router.DELETE("/api/uploads/:id", require, h.Delete) + router.DELETE("/api/media/:id", require, h.Delete) } type uploadRequest struct { @@ -55,57 +56,82 @@ type updateRequest struct { SortOrder *int `json:"sortOrder"` } -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// List godoc +// @Summary List gallery media +// @Tags media +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/galleries/{id}/media [get] +func (h *Handler) List(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - galleryID, err := parseID(r.PathValue("id")) + galleryID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid gallery id") + writeError(c, http.StatusBadRequest, "invalid gallery id") return } - belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID) + belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID) if err != nil || !belongs { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } - items, err := h.repository.ListByGallery(r.Context(), galleryID) + items, err := h.repository.ListByGallery(c.Request.Context(), galleryID) if err != nil { - writeError(w, http.StatusInternalServerError, "could not load media") + writeError(c, http.StatusInternalServerError, "could not load media") return } views := make([]Public, 0, len(items)) for _, item := range items { - view, err := h.view(r.Context(), item, true) + view, err := h.view(c.Request.Context(), item, true) if err != nil { - writeError(w, http.StatusInternalServerError, "could not sign media URLs") + writeError(c, http.StatusInternalServerError, "could not sign media URLs") return } views = append(views, view) } - writeJSON(w, http.StatusOK, map[string]any{"media": views}) + writeJSON(c, http.StatusOK, map[string]any{"media": views}) } -func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// CreateUpload godoc +// @Summary Create a presigned media upload +// @Tags media +// @Accept json +// @Produce json +// @Security studioSession +// @Param id path string true "Gallery UUID" +// @Param request body uploadRequest true "Upload metadata" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/galleries/{id}/uploads [post] +func (h *Handler) CreateUpload(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - galleryID, err := parseID(r.PathValue("id")) + galleryID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid gallery id") + writeError(c, http.StatusBadRequest, "invalid gallery id") return } - belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID) + belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID) if err != nil || !belongs { - writeError(w, http.StatusNotFound, "gallery not found") + writeError(c, http.StatusNotFound, "gallery not found") return } var request uploadRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } filename := safeFilename(request.Filename) @@ -114,145 +140,192 @@ func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) { mimeType = mime.TypeByExtension(filepath.Ext(filename)) } if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) { - writeError(w, http.StatusBadRequest, "unsupported media file") + writeError(c, http.StatusBadRequest, "unsupported media file") return } if request.FileSize <= 0 || request.FileSize > maxUploadSize { - writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB") + writeError(c, http.StatusBadRequest, "file size must be between 1 byte and 10 GB") return } mediaID := uuid.New() storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename) - item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey) + item, err := h.repository.Create(c.Request.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey) if err != nil { - writeError(w, http.StatusInternalServerError, "could not create upload") + writeError(c, http.StatusInternalServerError, "could not create upload") return } - uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration) + uploadURL, err := h.storage.CreateUploadURL(c.Request.Context(), storageKey, mimeType, uploadURLDuration) if err != nil { - _, _ = h.repository.Delete(r.Context(), user.ID, mediaID) - writeError(w, http.StatusInternalServerError, "could not create upload URL") + _, _ = h.repository.Delete(c.Request.Context(), user.ID, mediaID) + writeError(c, http.StatusInternalServerError, "could not create upload URL") return } - writeJSON(w, http.StatusCreated, map[string]any{ + writeJSON(c, http.StatusCreated, map[string]any{ "uploadId": mediaID.String(), "uploadUrl": uploadURL, "media": publicFromRecord(item), }) } -func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// CompleteUpload godoc +// @Summary Complete a media upload +// @Tags media +// @Produce json +// @Security studioSession +// @Param id path string true "Media UUID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/uploads/{id}/complete [post] +func (h *Handler) CompleteUpload(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - mediaID, err := parseID(r.PathValue("id")) + mediaID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } - item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID) + item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) if err != nil { - writeError(w, http.StatusNotFound, "media not found") + writeError(c, http.StatusNotFound, "media not found") return } - info, err := h.storage.Stat(r.Context(), item.StorageKey) + info, err := h.storage.Stat(c.Request.Context(), item.StorageKey) if err != nil { - writeError(w, http.StatusBadRequest, "uploaded object is not available yet") + writeError(c, http.StatusBadRequest, "uploaded object is not available yet") return } - item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size) + item, err = h.repository.Complete(c.Request.Context(), user.ID, mediaID, info.Size) if err != nil { - writeError(w, http.StatusInternalServerError, "could not complete upload") + writeError(c, http.StatusInternalServerError, "could not complete upload") return } h.processor.Enqueue(item.ID) - writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)}) + writeJSON(c, http.StatusOK, map[string]any{"media": publicFromRecord(item)}) } -func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Update godoc +// @Summary Update media ordering +// @Tags media +// @Accept json +// @Produce json +// @Security studioSession +// @Param id path string true "Media UUID" +// @Param request body updateRequest true "Media changes" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/media/{id} [patch] +func (h *Handler) Update(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - mediaID, err := parseID(r.PathValue("id")) + mediaID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } var request updateRequest - if !decodeJSON(w, r, &request) { + if !decodeJSON(c, &request) { return } if request.SortOrder == nil || *request.SortOrder < 0 { - writeError(w, http.StatusBadRequest, "sort order must be zero or greater") + writeError(c, http.StatusBadRequest, "sort order must be zero or greater") return } - if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil { - writeError(w, http.StatusNotFound, "media not found") + if err := h.repository.UpdateSortOrder(c.Request.Context(), user.ID, mediaID, *request.SortOrder); err != nil { + writeError(c, http.StatusNotFound, "media not found") return } - item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID) - view, err := h.view(r.Context(), item, true) + item, _ := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) + view, err := h.view(c.Request.Context(), item, true) if err != nil { - writeError(w, http.StatusInternalServerError, "could not sign media URL") + writeError(c, http.StatusInternalServerError, "could not sign media URL") return } - writeJSON(w, http.StatusOK, map[string]any{"media": view}) + writeJSON(c, http.StatusOK, map[string]any{"media": view}) } -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Delete godoc +// @Summary Delete media or cancel an upload +// @Tags media +// @Produce json +// @Security studioSession +// @Param id path string true "Media UUID" +// @Success 204 +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/uploads/{id} [delete] +// @Router /api/media/{id} [delete] +func (h *Handler) Delete(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - mediaID, err := parseID(r.PathValue("id")) + mediaID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } - item, err := h.repository.Delete(r.Context(), user.ID, mediaID) + item, err := h.repository.Delete(c.Request.Context(), user.ID, mediaID) if err != nil { - writeError(w, http.StatusNotFound, "media not found") + writeError(c, http.StatusNotFound, "media not found") return } for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} { if key != "" && key != item.ExternalURL { - _ = h.storage.Delete(r.Context(), key) + _ = h.storage.Delete(c.Request.Context(), key) } } - w.WriteHeader(http.StatusNoContent) + c.Status(http.StatusNoContent) } -func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { - user, ok := auth.UserFromContext(r.Context()) +// Download godoc +// @Summary Create a signed original media download URL +// @Tags media +// @Produce json +// @Security studioSession +// @Param id path string true "Media UUID" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/media/{id}/download [post] +func (h *Handler) Download(c *gin.Context) { + user, ok := auth.UserFromContext(c) if !ok { - writeError(w, http.StatusUnauthorized, "authentication required") + writeError(c, http.StatusUnauthorized, "authentication required") return } - mediaID, err := parseID(r.PathValue("id")) + mediaID, err := parseID(c.Param("id")) if err != nil { - writeError(w, http.StatusBadRequest, "invalid media id") + writeError(c, http.StatusBadRequest, "invalid media id") return } - item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID) + item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID) if err != nil || item.ProcessingStatus != StatusReady { - writeError(w, http.StatusNotFound, "media not found") + writeError(c, http.StatusNotFound, "media not found") return } url := item.ExternalURL if url == "" { - url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour) + url, err = h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour) if err != nil { - writeError(w, http.StatusInternalServerError, "could not create download") + writeError(c, http.StatusInternalServerError, "could not create download") return } } - _ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String()) - writeJSON(w, http.StatusOK, map[string]string{"url": url}) + _ = h.repository.RecordDownload(c.Request.Context(), item.GalleryID, &item.ID, user.ID.String()) + writeJSON(c, http.StatusOK, map[string]string{"url": url}) } func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) { @@ -330,26 +403,24 @@ func parseID(value string) (uuid.UUID, error) { return uuid.Parse(value) } -func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { - writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") +func decodeJSON(c *gin.Context, target any) bool { + if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { + writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json") return false } - decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { - writeError(w, http.StatusBadRequest, "invalid JSON body") + writeError(c, http.StatusBadRequest, "invalid JSON body") return false } return true } -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) +func writeError(c *gin.Context, status int, message string) { + writeJSON(c, status, map[string]string{"error": message}) } -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) +func writeJSON(c *gin.Context, status int, value any) { + c.JSON(status, value) } diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 827305f..041acde 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -82,7 +82,7 @@ func (s *MinIO) EnsureBucket(ctx context.Context) error { origins = []string{"*"} } if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{ - ID: "northline-browser-uploads", + ID: "studio-browser-uploads", AllowedOrigin: origins, AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"}, AllowedHeader: []string{"*"}, diff --git a/docker-compose.yml b/docker-compose.yml index 91ee876..f9682a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: environment: MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin} MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin} - MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173} + MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:8081} ports: - "${MINIO_API_PORT:-9000}:9000" - "${MINIO_CONSOLE_PORT:-9001}:9001" @@ -36,6 +36,62 @@ services: timeout: 5s retries: 10 + api: + build: ./backend + restart: unless-stopped + ports: + - "${PORT:-8080}:8080" + environment: + DB_DRIVER: postgres + DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable + PORT: "8080" + CORS_ORIGIN: "http://localhost:8081" + SESSION_SECRET: "${SESSION_SECRET:-docker-compose-session-secret-change-me}" + COOKIE_SECURE: "${COOKIE_SECURE:-false}" + STORAGE_ENDPOINT: "minio:9000" + STORAGE_ACCESS_KEY: "${STORAGE_ACCESS_KEY:-minioadmin}" + STORAGE_SECRET_KEY: "${STORAGE_SECRET_KEY:-minioadmin}" + STORAGE_BUCKET: "${STORAGE_BUCKET:-gallery-media}" + STORAGE_USE_SSL: "${STORAGE_USE_SSL:-false}" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + + migrate: + build: ./backend + command: ["migrate", "-dir", "/migrations"] + environment: + DB_DRIVER: postgres + DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable + volumes: + - ./migrations:/migrations:ro + depends_on: + postgres: + condition: service_healthy + + seed: + build: ./backend + command: ["seed"] + environment: + DB_DRIVER: postgres + DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable + depends_on: + postgres: + condition: service_healthy + + frontend: + build: + context: ./frontend + args: + VITE_API_BASE_URL: "" + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-8081}:80" + depends_on: + - api + volumes: postgres_data: - minio_data: + minio_data: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..bb33675 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +ARG VITE_API_BASE_URL= +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index f59b3d4..e2c8451 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,7 +8,7 @@ name="description" content="Beautiful private galleries for photographers and their clients." /> - Northline Delivery Studio + Noah Bianchi Studio
diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d738586 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + root /usr/share/nginx/html; + + location /api/ { + proxy_pass http://api:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger/ { + proxy_pass http://api:8080/swagger/; + proxy_set_header Host $host; + } + + location /health { + proxy_pass http://api:8080/health; + proxy_set_header Host $host; + } + + location / { + try_files $uri /index.html; + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43f94e9..2220f92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import GalleriesPage from './pages/dashboard/GalleriesPage'; import GalleryEditorPage from './pages/dashboard/GalleryEditorPage'; import PlaceholderPage from './pages/dashboard/PlaceholderPage'; import DevPage from './pages/dashboard/DevPage'; +import SettingsPage from './pages/dashboard/SettingsPage'; import NotFoundPage from './pages/NotFoundPage'; export default function App() { @@ -67,7 +68,7 @@ export default function App() { } @@ -76,10 +77,7 @@ export default function App() { path="/dashboard/settings" element={ - + } /> diff --git a/frontend/src/components/app/AuthLayout.tsx b/frontend/src/components/app/AuthLayout.tsx index 781bde4..0e5d7e3 100644 --- a/frontend/src/components/app/AuthLayout.tsx +++ b/frontend/src/components/app/AuthLayout.tsx @@ -6,16 +6,12 @@ export function AuthLayout({ children }: { children: ReactNode }) { diff --git a/frontend/src/components/dashboard/DashboardLayout.tsx b/frontend/src/components/dashboard/DashboardLayout.tsx index c003559..ed14f51 100644 --- a/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/frontend/src/components/dashboard/DashboardLayout.tsx @@ -26,8 +26,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) { N - Northline - delivery studio + Noah Bianchi + photography studio
Workspace
@@ -48,8 +48,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
+ - Make it memorable. - Your work deserves a proper handoff. + System Status + Delivery console active.
@@ -73,7 +73,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
N - Northline + Noah Bianchi
- Scroll to wander + Scroll for media
-

The collection

+

Gallery Media

- The day, held still. + Photos & video.

@@ -186,7 +186,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) { {items.length === 0 ? (
+ -

Your gallery is still being arranged.

+

No media uploaded.

) : (
@@ -206,13 +206,13 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
-

Take it with you

+

Downloads

- The moments are + Download
- yours to keep. + originals.

-

Save the full-resolution photographs and revisit this chapter whenever you like.

+

Download the original files from this gallery.

{gallery.downloadsEnabled !== false && (
@@ -269,7 +269,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) { )}
- Delivered with intention + Noah Bianchi diff --git a/frontend/src/components/gallery/PasswordGate.tsx b/frontend/src/components/gallery/PasswordGate.tsx index c84a1fa..d1620f8 100644 --- a/frontend/src/components/gallery/PasswordGate.tsx +++ b/frontend/src/components/gallery/PasswordGate.tsx @@ -27,7 +27,6 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) { return (
- ); } diff --git a/frontend/src/components/gift/Background.tsx b/frontend/src/components/gift/Background.tsx deleted file mode 100644 index 5a65ff7..0000000 --- a/frontend/src/components/gift/Background.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { ReactNode } from 'react'; - -export type BackgroundTone = 'intro' | 'reveal' | 'content' | 'finish'; - -interface BackgroundProps { - children: ReactNode; - tone?: BackgroundTone; -} - -export function Background({ children, tone = 'intro' }: BackgroundProps) { - return ( -
-