ai slop ah

This commit is contained in:
2026-08-22 17:27:55 +02:00
parent 6a5bb1d699
commit dc124d0d77
64 changed files with 7308 additions and 2448 deletions
+4 -1
View File
@@ -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: dev:
go -C backend run ./cmd/server go -C backend run ./cmd/server
@@ -12,6 +12,9 @@ migrate:
seed: seed:
go -C backend run ./cmd/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: test:
go -C backend test ./... go -C backend test ./...
+16 -5
View File
@@ -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 ```text
Upload -> Curate -> Customize -> Publish -> Send a private link 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 ## Stack
- Frontend: React, TypeScript, Vite, React Router, Tailwind CSS, Framer Motion - 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 - Database: PostgreSQL by default; SQLite remains available for lightweight local testing
- Storage: MinIO locally through an S3-compatible storage interface - Storage: MinIO locally through an S3-compatible storage interface
- Media: direct browser uploads with presigned URLs, asynchronous preview processing, signed download URLs - 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) - Dashboard: [http://localhost:5173/login](http://localhost:5173/login)
- Dev diagnostics: [http://localhost:5173/dashboard/dev](http://localhost:5173/dashboard/dev) - 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) - 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) - MinIO console: [http://localhost:9001](http://localhost:9001)
### PowerShell ### PowerShell
@@ -78,7 +79,7 @@ The seed command creates:
```text ```text
Email: demo@example.com Email: demo@example.com
Password: DemoPassword123! Password: DemoPassword123!
Studio: Northline Studio Studio: Noah Bianchi
``` ```
It also creates the published demo gallery: It also creates the published demo gallery:
@@ -187,6 +188,8 @@ POST /api/auth/register
POST /api/auth/login POST /api/auth/login
POST /api/auth/logout POST /api/auth/logout
GET /api/auth/me GET /api/auth/me
PATCH /api/auth/me
POST /api/auth/password
``` ```
Authenticated galleries: Authenticated galleries:
@@ -232,6 +235,12 @@ Health:
GET /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. 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 ## Database
@@ -256,7 +265,7 @@ The schema does not store uploaded media content. `external_url` exists only to
```text ```text
backend/ backend/
cmd/server/ API and worker startup cmd/server/ Gin API router and worker startup
cmd/migrate/ dialect-aware SQL migration runner cmd/migrate/ dialect-aware SQL migration runner
cmd/seed/ demo photographer and gallery seed cmd/seed/ demo photographer and gallery seed
internal/auth/ bcrypt auth, signed sessions, visitor access cookies 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: Backend:
```bash ```bash
make test
make lint
``` ```
Frontend: Frontend:
+14
View File
@@ -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"]
+3 -3
View File
@@ -29,7 +29,7 @@ func main() {
if err := seed(ctx, database); err != nil { if err := seed(ctx, database); err != nil {
log.Fatal(err) 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 { 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) INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP 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) 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 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, `, 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"}`, `{"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) return fmt.Errorf("seed demo gallery: %w", err)
} }
+55 -36
View File
@@ -10,17 +10,29 @@ import (
"syscall" "syscall"
"time" "time"
_ "github.com/example/sndit/backend/docs"
"github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/config" "github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db" "github.com/example/sndit/backend/internal/db"
devtools "github.com/example/sndit/backend/internal/dev" devtools "github.com/example/sndit/backend/internal/dev"
"github.com/example/sndit/backend/internal/downloads" "github.com/example/sndit/backend/internal/downloads"
"github.com/example/sndit/backend/internal/galleries" "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/media"
"github.com/example/sndit/backend/internal/storage" "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=<value>.
func main() { func main() {
cfg := config.Load() cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -73,23 +85,25 @@ func main() {
CookieSecure: cfg.CookieSecure, CookieSecure: cfg.CookieSecure,
}) })
mux := http.NewServeMux() router := gin.New()
mux.HandleFunc("GET /health", health) router.Use(gin.Recovery(), withCORS(cfg.CORSOrigin), withLogging())
authHandler.RegisterRoutes(mux) router.GET("/health", health)
galleryHandler.RegisterProtectedRoutes(mux, authService.Require) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler,
mediaHandler.RegisterRoutes(mux, authService.Require) ginSwagger.DocExpansion("none"),
galleryHandler.RegisterPublicRoutes(mux) ginSwagger.PersistAuthorization(true),
downloadHandler.RegisterRoutes(mux) ))
devHandler.RegisterRoutes(mux, authService.Require) require := authService.Require()
authHandler.RegisterRoutes(router)
// Keep the original public gift endpoint available while the gallery product authHandler.RegisterProtectedRoutes(router, require)
// uses /api/public/galleries/:slug. galleryHandler.RegisterProtectedRoutes(router, require)
legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database))) mediaHandler.RegisterRoutes(router, require)
mux.Handle("/api/gifts/", legacyGifts.Routes()) galleryHandler.RegisterPublicRoutes(router)
downloadHandler.RegisterRoutes(router)
devHandler.RegisterRoutes(router, require)
server := &http.Server{ server := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: withCORS(withLogging(mux), cfg.CORSOrigin), Handler: router,
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
@@ -111,28 +125,33 @@ func main() {
} }
} }
func health(w http.ResponseWriter, _ *http.Request) { // health godoc
w.Header().Set("Content-Type", "application/json; charset=utf-8") // @Summary Check API health
w.WriteHeader(http.StatusOK) // @Tags system
_, _ = w.Write([]byte(`{"status":"ok"}` + "\n")) // @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 { func withCORS(allowedOrigin string) gin.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(c *gin.Context) {
origin := r.Header.Get("Origin") origin := c.GetHeader("Origin")
if origin != "" && originAllowed(origin, allowedOrigin) { if origin != "" && originAllowed(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true") c.Header("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin") c.Header("Vary", "Origin")
} }
if r.Method == http.MethodOptions { if c.Request.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") c.Header("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent) c.Status(http.StatusNoContent)
c.Abort()
return return
} }
next.ServeHTTP(w, r) c.Next()
}) }
} }
func originAllowed(origin, configured string) bool { func originAllowed(origin, configured string) bool {
@@ -144,10 +163,10 @@ func originAllowed(origin, configured string) bool {
return false return false
} }
func withLogging(next http.Handler) http.Handler { func withLogging() gin.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(c *gin.Context) {
started := time.Now() started := time.Now()
next.ServeHTTP(w, r) c.Next()
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond)) log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started).Round(time.Millisecond))
}) }
} }
+1828
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -3,36 +3,74 @@ module github.com/example/sndit/backend
go 1.24.0 go 1.24.0
require ( require (
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/minio/minio-go/v7 v7.0.95 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 modernc.org/sqlite v1.39.1
) )
require ( 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/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-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-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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/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/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.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/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/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/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/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.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/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/sync v0.16.0 // indirect
golang.org/x/sys v0.36.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/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+145 -10
View File
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= 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/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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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.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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg= 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/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 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo= 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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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.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.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.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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 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 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/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 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 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 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-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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
+160 -31
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type Handler struct { type Handler struct {
@@ -15,11 +17,16 @@ func NewHandler(service *Service) *Handler {
return &Handler{service: service} return &Handler{service: service}
} }
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(router gin.IRouter) {
mux.HandleFunc("POST /api/auth/register", h.Register) router.POST("/api/auth/register", h.Register)
mux.HandleFunc("POST /api/auth/login", h.Login) router.POST("/api/auth/login", h.Login)
mux.HandleFunc("POST /api/auth/logout", h.Logout) router.POST("/api/auth/logout", h.Logout)
mux.HandleFunc("GET /api/auth/me", h.Me) 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 { type credentialsRequest struct {
@@ -28,66 +35,188 @@ type credentialsRequest struct {
Name string `json:"name"` 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 var request credentialsRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return 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 err != nil {
if errors.Is(err, ErrEmailTaken) { 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 return
} }
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
return return
} }
h.service.SetSession(w, user) h.service.SetSession(c, user)
writeJSON(w, http.StatusCreated, map[string]User{"user": 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 var request credentialsRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return 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 err != nil {
if errors.Is(err, ErrInvalidCredentials) { 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 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 return
} }
h.service.SetSession(w, user) h.service.SetSession(c, user)
writeJSON(w, http.StatusOK, map[string]User{"user": user}) writeJSON(c, http.StatusOK, map[string]User{"user": user})
} }
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) { // Logout godoc
h.service.ClearSession(w) // @Summary Sign out the current photographer
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) // @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) { // Me godoc
user, err := h.service.UserFromRequest(r.Context(), r) // @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 { if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return 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 { // UpdateMe godoc
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { // @Summary Update the current photographer profile
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"}) // @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 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() decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil { 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 false
} }
return true return true
} }
func writeJSON(c *gin.Context, status int, value any) {
c.JSON(status, value)
}
+47
View File
@@ -66,3 +66,50 @@ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
} }
return user, nil 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
}
+59 -58
View File
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -29,6 +30,7 @@ const (
var ( var (
ErrInvalidCredentials = errors.New("invalid credentials") ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidSession = errors.New("invalid session") ErrInvalidSession = errors.New("invalid session")
ErrCurrentPassword = errors.New("current password is incorrect")
) )
type Service struct { type Service struct {
@@ -78,33 +80,47 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro
return user.User, nil 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()} payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()}
token, err := s.signJSON(payload) token, err := s.signJSON(payload)
if err != nil { if err != nil {
return return
} }
http.SetCookie(w, &http.Cookie{ s.setCookie(c, sessionCookieName, token, int(sessionDuration.Seconds()), true)
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
} }
func (s *Service) ClearSession(w http.ResponseWriter) { func (s *Service) ClearSession(c *gin.Context) {
http.SetCookie(w, &http.Cookie{ s.setCookie(c, sessionCookieName, "", -1, true)
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
} }
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) { func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) {
@@ -131,61 +147,47 @@ type contextKey string
const userContextKey contextKey = "authenticated-user" const userContextKey contextKey = "authenticated-user"
func (s *Service) Require(next http.Handler) http.Handler { func (s *Service) Require() gin.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return func(c *gin.Context) {
user, err := s.UserFromRequest(r.Context(), r) user, err := s.UserFromRequest(c.Request.Context(), c.Request)
if err != nil { if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) c.AbortWithStatusJSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return 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) { func UserFromContext(c *gin.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User) value, ok := c.Get(userContextKey)
return user, ok user, valid := value.(User)
return user, ok && valid
} }
func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string { func (s *Service) EnsureVisitor(c *gin.Context) string {
if cookie, err := r.Cookie(visitorCookieName); err == nil { if cookie, err := c.Request.Cookie(visitorCookieName); err == nil {
if _, err := uuid.Parse(cookie.Value); err == nil { if _, err := uuid.Parse(cookie.Value); err == nil {
return cookie.Value return cookie.Value
} }
} }
visitorID := uuid.NewString() visitorID := uuid.NewString()
http.SetCookie(w, &http.Cookie{ s.setCookie(c, visitorCookieName, visitorID, int(365*24*time.Hour/time.Second), true)
Name: visitorCookieName,
Value: visitorID,
Path: "/",
MaxAge: int(365 * 24 * time.Hour / time.Second),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
return visitorID 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()} payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()}
token, err := s.signJSON(payload) token, err := s.signJSON(payload)
if err != nil { if err != nil {
return return
} }
http.SetCookie(w, &http.Cookie{ s.setCookie(c, accessCookieName, token, int(accessDuration.Seconds()), true)
Name: accessCookieName,
Value: token,
Path: "/",
MaxAge: int(accessDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
} }
func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool { func (s *Service) HasGalleryAccess(c *gin.Context, slug string) bool {
cookie, err := r.Cookie(accessCookieName) cookie, err := c.Request.Cookie(accessCookieName)
if err != nil { if err != nil {
return false 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() 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 { type sessionPayload struct {
UserID string `json:"userId"` UserID string `json:"userId"`
ExpiresAt int64 `json:"expiresAt"` ExpiresAt int64 `json:"expiresAt"`
@@ -235,9 +242,3 @@ func (s *Service) signature(value string) string {
_, _ = hash.Write([]byte(value)) _, _ = hash.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil)) 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)
}
+19 -2
View File
@@ -2,11 +2,13 @@ package auth
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
appdb "github.com/example/sndit/backend/internal/db" appdb "github.com/example/sndit/backend/internal/db"
"github.com/gin-gonic/gin"
) )
func TestRegisterLoginAndSession(t *testing.T) { func TestRegisterLoginAndSession(t *testing.T) {
@@ -33,7 +35,7 @@ func TestRegisterLoginAndSession(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("create auth service: %v", err) 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 { if err != nil {
t.Fatalf("register user: %v", err) t.Fatalf("register user: %v", err)
} }
@@ -44,9 +46,24 @@ func TestRegisterLoginAndSession(t *testing.T) {
if err != nil || loggedIn.ID != user.ID { if err != nil || loggedIn.ID != user.ID {
t.Fatalf("login failed: user=%+v err=%v", loggedIn, err) 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() 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 := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
request.AddCookie(recorder.Result().Cookies()[0]) request.AddCookie(recorder.Result().Cookies()[0])
fromSession, err := service.UserFromRequest(ctx, request) fromSession, err := service.UserFromRequest(ctx, request)
+41 -25
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -13,6 +12,7 @@ import (
"github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage" "github.com/example/sndit/backend/internal/storage"
"github.com/gin-gonic/gin"
) )
type Config struct { 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} return &Handler{db: db, storage: objectStorage, auth: authService, config: config}
} }
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics))) router.GET("/api/dev/diagnostics", require, h.Diagnostics)
mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck))) router.POST("/api/dev/storage-check", require, h.StorageCheck)
} }
func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) { // Diagnostics godoc
user, _ := auth.UserFromContext(r.Context()) // @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 := "" 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 { if err := h.db.PingContext(databaseContext); err != nil {
databaseError = err.Error() databaseError = err.Error()
} }
cancel() cancel()
storageError := "" 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 { if err := h.storage.EnsureBucket(storageContext); err != nil {
storageError = err.Error() storageError = err.Error()
} }
storageCancel() storageCancel()
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(c, http.StatusOK, map[string]any{
"environment": "development", "environment": "development",
"now": time.Now().UTC().Format(time.RFC3339), "now": time.Now().UTC().Format(time.RFC3339),
"user": map[string]string{ "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) { // StorageCheck godoc
if err := h.storage.EnsureBucket(r.Context()); err != nil { // @Summary Check MinIO storage read/write access
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()}) // @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 return
} }
var randomBytes [12]byte var randomBytes [12]byte
if _, err := rand.Read(randomBytes[:]); err != nil { 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 return
} }
key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:])) key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:]))
contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano) contents := "studio 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 { if err := h.storage.Put(c.Request.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()}) writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
return return
} }
info, statErr := h.storage.Stat(r.Context(), key) info, statErr := h.storage.Stat(c.Request.Context(), key)
deleteErr := h.storage.Delete(r.Context(), key) deleteErr := h.storage.Delete(c.Request.Context(), key)
if statErr != nil { 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 return
} }
if deleteErr != nil { 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 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 { func splitOrigins(value string) []string {
@@ -123,8 +141,6 @@ func splitOrigins(value string) []string {
return result return result
} }
func writeJSON(w http.ResponseWriter, status int, value any) { func writeJSON(c *gin.Context, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") c.JSON(status, value)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
} }
+81 -49
View File
@@ -1,7 +1,6 @@
package downloads package downloads
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -10,6 +9,7 @@ import (
"github.com/example/sndit/backend/internal/galleries" "github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage" "github.com/example/sndit/backend/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "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} return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
} }
func (h *Handler) RegisterRoutes(mux *http.ServeMux) { func (h *Handler) RegisterRoutes(router gin.IRouter) {
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download) router.POST("/api/public/galleries/:slug/media/:mediaId/download", h.Download)
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll) router.POST("/api/public/galleries/:slug/download-all", h.DownloadAll)
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus) router.GET("/api/public/galleries/:slug/download-all/:jobId", h.DownloadAllStatus)
} }
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) { // Download godoc
record, err := h.publicRecord(r) // @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 { if err != nil {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
if !record.DownloadsEnabled { if !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "downloads are disabled") writeError(c, http.StatusForbidden, "downloads are disabled")
return return
} }
mediaID, err := uuid.Parse(r.PathValue("mediaId")) mediaID, err := uuid.Parse(c.Param("mediaId"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return 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 { 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 return
} }
url, err := h.downloadURL(r, item) url, err := h.downloadURL(c, item)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download") writeError(c, http.StatusInternalServerError, "could not create download")
return return
} }
visitorID := h.auth.EnsureVisitor(w, r) visitorID := h.auth.EnsureVisitor(c)
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID) _ = h.media.RecordDownload(c.Request.Context(), record.ID, &mediaID, visitorID)
writeJSON(w, http.StatusOK, map[string]string{"url": url}) writeJSON(c, http.StatusOK, map[string]string{"url": url})
} }
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) { // DownloadAll godoc
record, err := h.publicRecord(r) // @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 { if err != nil {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
if !record.DownloadAllEnabled || !record.DownloadsEnabled { if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled") writeError(c, http.StatusForbidden, "gallery downloads are disabled")
return return
} }
visitorID := h.auth.EnsureVisitor(w, r) visitorID := h.auth.EnsureVisitor(c)
job, err := h.service.Create(r.Context(), record.ID, visitorID) job, err := h.service.Create(c.Request.Context(), record.ID, visitorID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not start gallery download") writeError(c, http.StatusInternalServerError, "could not start gallery download")
return 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) { // DownloadAllStatus godoc
record, err := h.publicRecord(r) // @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 { if err != nil {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
if !record.DownloadAllEnabled || !record.DownloadsEnabled { if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled") writeError(c, http.StatusForbidden, "gallery downloads are disabled")
return return
} }
jobID, err := uuid.Parse(r.PathValue("jobId")) jobID, err := uuid.Parse(c.Param("jobId"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid download job id") writeError(c, http.StatusBadRequest, "invalid download job id")
return return
} }
visitorID := h.auth.EnsureVisitor(w, r) visitorID := h.auth.EnsureVisitor(c)
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID) job, err := h.service.Get(c.Request.Context(), jobID, record.ID, visitorID)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "download job not found") writeError(c, http.StatusNotFound, "download job not found")
return return
} }
response := map[string]any{"jobId": job.ID.String(), "status": job.Status} 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 response["error"] = job.Error
} }
if job.Status == StatusReady { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download URL") writeError(c, http.StatusInternalServerError, "could not create download URL")
return return
} }
response["url"] = url response["url"] = url
} }
writeJSON(w, http.StatusOK, response) writeJSON(c, http.StatusOK, response)
} }
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) { func (h *Handler) publicRecord(c *gin.Context) (galleries.GalleryRecord, error) {
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) record, err := h.galleries.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
if err != nil || record.IsExpired() { if err != nil || record.IsExpired() {
return galleries.GalleryRecord{}, galleries.ErrNotFound 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 galleries.GalleryRecord{}, galleries.ErrNotFound
} }
return record, nil 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 != "" { if item.ExternalURL != "" {
return item.ExternalURL, nil 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) { func writeError(c *gin.Context, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(c, status, map[string]string{"error": message})
} }
func writeJSON(w http.ResponseWriter, status int, value any) { func writeJSON(c *gin.Context, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") c.JSON(status, value)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
} }
+274 -150
View File
@@ -12,6 +12,7 @@ import (
"github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage" "github.com/example/sndit/backend/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "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) { func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List))) router.GET("/api/galleries", require, h.List)
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create))) router.POST("/api/galleries", require, h.Create)
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get))) router.GET("/api/galleries/:id", require, h.Get)
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update))) router.PATCH("/api/galleries/:id", require, h.Update)
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete))) router.DELETE("/api/galleries/:id", require, h.Delete)
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish))) router.POST("/api/galleries/:id/publish", require, h.Publish)
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish))) router.POST("/api/galleries/:id/unpublish", require, h.Unpublish)
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview))) router.GET("/api/galleries/:id/preview", require, h.Preview)
} }
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) { func (h *Handler) RegisterPublicRoutes(router gin.IRouter) {
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public) router.GET("/api/public/galleries/:slug", h.Public)
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic) router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic)
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite) router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite)
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite) router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite)
} }
type createRequest struct { type createRequest struct {
@@ -78,15 +79,23 @@ type publicPasswordRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { // List godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
summaries, err := h.repository.ListForUser(r.Context(), user.ID) summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load galleries") writeError(c, http.StatusInternalServerError, "could not load galleries")
return return
} }
for index := range summaries { for index := range summaries {
@@ -97,97 +106,141 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
continue 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 { if err != nil {
continue 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) { // Create godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
var request createRequest var request createRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return return
} }
request.Title = strings.TrimSpace(request.Title) request.Title = strings.TrimSpace(request.Title)
request.ClientName = strings.TrimSpace(request.ClientName) request.ClientName = strings.TrimSpace(request.ClientName)
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 { 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 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not create gallery") writeError(c, http.StatusInternalServerError, "could not create gallery")
return 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) { // Get godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
record, err := h.recordForUser(r, user.ID) record, err := h.recordForUser(c, user.ID)
if err != nil { if err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return return
} }
items, err := h.media.ListByGallery(r.Context(), record.ID) items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media") writeError(c, http.StatusInternalServerError, "could not load gallery media")
return return
} }
views, err := h.mediaViews(r.Context(), items, "", true) views, err := h.mediaViews(c.Request.Context(), items, "", true)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs") writeError(c, http.StatusInternalServerError, "could not sign media URLs")
return 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) { // Preview godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
record, err := h.recordForUser(r, user.ID) record, err := h.recordForUser(c, user.ID)
if err != nil { if err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return return
} }
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "") gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "")
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not build gallery preview") writeError(c, http.StatusInternalServerError, "could not build gallery preview")
return return
} }
gallery.Preview = true 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) { // Update godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
current, err := h.recordForUser(r, user.ID) current, err := h.recordForUser(c, user.ID)
if err != nil { if err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return return
} }
var request updateRequest var request updateRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return return
} }
@@ -217,19 +270,19 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
input.Description = strings.TrimSpace(*request.Description) input.Description = strings.TrimSpace(*request.Description)
} }
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 { 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 return
} }
if request.Password != nil { if request.Password != nil {
if strings.TrimSpace(*request.Password) == "" { if strings.TrimSpace(*request.Password) == "" {
input.ClearPassword = true input.ClearPassword = true
} else if len(*request.Password) < 4 { } 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 return
} else { } else {
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not secure gallery password") writeError(c, http.StatusInternalServerError, "could not secure gallery password")
return return
} }
hashed := string(hash) hashed := string(hash)
@@ -257,7 +310,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
value := strings.TrimSpace(*request.ExpiresAt) value := strings.TrimSpace(*request.ExpiresAt)
if value != "" { if value != "" {
if _, err := time.Parse(time.RFC3339, value); err != nil { 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 return
} }
} }
@@ -268,12 +321,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
if value != "" { if value != "" {
coverID, err := uuid.Parse(value) coverID, err := uuid.Parse(value)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid cover media id") writeError(c, http.StatusBadRequest, "invalid cover media id")
return 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 { 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 return
} }
} }
@@ -281,182 +334,255 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
} }
if len(request.ThemeConfig) > 0 { if len(request.ThemeConfig) > 0 {
if !json.Valid(request.ThemeConfig) { 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 return
} }
input.ThemeConfig = request.ThemeConfig input.ThemeConfig = request.ThemeConfig
} }
if len(request.BrandingConfig) > 0 { if len(request.BrandingConfig) > 0 {
if !json.Valid(request.BrandingConfig) { 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 return
} }
input.BrandingConfig = request.BrandingConfig 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery") writeError(c, http.StatusInternalServerError, "could not update gallery")
return return
} }
items, err := h.media.ListByGallery(r.Context(), record.ID) items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media") writeError(c, http.StatusInternalServerError, "could not load gallery media")
return return
} }
views, err := h.mediaViews(r.Context(), items, "", true) views, err := h.mediaViews(c.Request.Context(), items, "", true)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs") writeError(c, http.StatusInternalServerError, "could not sign media URLs")
return 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) { // Publish godoc
h.setStatus(w, r, StatusPublished) // @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) { // Unpublish godoc
h.setStatus(w, r, StatusDraft) // @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) { func (h *Handler) setStatus(c *gin.Context, status string) {
user, ok := auth.UserFromContext(r.Context()) user, ok := auth.UserFromContext(c)
if !ok { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
record, err := h.recordForUser(r, user.ID) record, err := h.recordForUser(c, user.ID)
if err != nil { if err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery status") writeError(c, http.StatusInternalServerError, "could not update gallery status")
return 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) { // Delete godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
id, err := pathUUID(r, "id") id, err := pathUUID(c, "id")
if err != nil { if err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return return
} }
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil { if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil {
writeGalleryError(w, err) writeGalleryError(c, err)
return return
} }
w.WriteHeader(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) { // Public godoc
slug := strings.TrimSpace(r.PathValue("slug")) // @Summary Get a published public gallery
record, err := h.repository.GetPublicBySlug(r.Context(), slug) // @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() { if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) { if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
gallery := h.lockedPayload(record) gallery := h.lockedPayload(record)
writeJSON(w, http.StatusOK, gallery) writeJSON(c, http.StatusOK, gallery)
return return
} }
visitorID := "" visitorID := ""
if record.FavoritesEnabled { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery") writeError(c, http.StatusInternalServerError, "could not load gallery")
return return
} }
writeJSON(w, http.StatusOK, gallery) writeJSON(c, http.StatusOK, gallery)
} }
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) { // AuthenticatePublic godoc
slug := strings.TrimSpace(r.PathValue("slug")) // @Summary Authenticate to a password-protected gallery
record, err := h.repository.GetPublicBySlug(r.Context(), slug) // @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() { if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
var request publicPasswordRequest var request publicPasswordRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return return
} }
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil { 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 return
} }
h.auth.GrantGalleryAccess(w, record.Slug) h.auth.GrantGalleryAccess(c, record.Slug)
visitorID := "" visitorID := ""
if record.FavoritesEnabled { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery") writeError(c, http.StatusInternalServerError, "could not load gallery")
return return
} }
writeJSON(w, http.StatusOK, gallery) writeJSON(c, http.StatusOK, gallery)
} }
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) { // Favorite godoc
h.setFavorite(w, r, true) // @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) { // Unfavorite godoc
h.setFavorite(w, r, false) // @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) { func (h *Handler) setFavorite(c *gin.Context, favorited bool) {
record, err := h.publicRecordForRequest(r) record, err := h.publicRecordForRequest(c)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
if !record.FavoritesEnabled { if !record.FavoritesEnabled {
writeError(w, http.StatusForbidden, "favorites are disabled") writeError(c, http.StatusForbidden, "favorites are disabled")
return return
} }
mediaID, err := pathUUID(r, "mediaId") mediaID, err := pathUUID(c, "mediaId")
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return 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 { if err != nil || item.GalleryID != record.ID {
writeError(w, http.StatusNotFound, "media not found") writeError(c, http.StatusNotFound, "media not found")
return return
} }
visitorID := h.auth.EnsureVisitor(w, r) visitorID := h.auth.EnsureVisitor(c)
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil { if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
writeError(w, http.StatusInternalServerError, "could not update favorite") writeError(c, http.StatusInternalServerError, "could not update favorite")
return 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) { func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) {
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug"))) record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
if err != nil || record.IsExpired() { if err != nil || record.IsExpired() {
return GalleryRecord{}, ErrNotFound 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 GalleryRecord{}, ErrNotFound
} }
return record, nil 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) items, err := h.media.ListByGallery(ctx, record.ID)
if err != nil { if err != nil {
return Public{}, err 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) { func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) {
id, err := pathUUID(r, "id") id, err := pathUUID(c, "id")
if err != nil { if err != nil {
return GalleryRecord{}, err 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 { func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
@@ -628,8 +754,8 @@ func newSlug(title string) string {
return slug + "-" + suffix return slug + "-" + suffix
} }
func pathUUID(r *http.Request, name string) (uuid.UUID, error) { func pathUUID(c *gin.Context, name string) (uuid.UUID, error) {
id, err := uuid.Parse(r.PathValue(name)) id, err := uuid.Parse(c.Param(name))
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("invalid %s", name) return uuid.Nil, fmt.Errorf("invalid %s", name)
} }
@@ -644,34 +770,32 @@ func stringPointer(value string) *string {
return &copy return &copy
} }
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { func decodeJSON(c *gin.Context, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
return false 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() decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil { if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body") writeError(c, http.StatusBadRequest, "invalid JSON body")
return false return false
} }
return true 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 ") { 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 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) { func writeError(c *gin.Context, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(c, status, map[string]string{"error": message})
} }
func writeJSON(w http.ResponseWriter, status int, value any) { func writeJSON(c *gin.Context, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") c.JSON(status, value)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
} }
@@ -13,6 +13,7 @@ import (
"github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/auth"
appdb "github.com/example/sndit/backend/internal/db" appdb "github.com/example/sndit/backend/internal/db"
"github.com/example/sndit/backend/internal/media" "github.com/example/sndit/backend/internal/media"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -40,13 +41,13 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666") galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777") 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) t.Fatalf("insert user: %v", err)
} }
if _, err := database.ExecContext(ctx, ` if _, err := database.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config) 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) 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) t.Fatalf("insert gallery: %v", err)
} }
if _, err := database.ExecContext(ctx, ` if _, err := database.ExecContext(ctx, `
@@ -61,12 +62,12 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
t.Fatalf("create auth service: %v", err) t.Fatalf("create auth service: %v", err)
} }
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService) handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
mux := http.NewServeMux() router := gin.New()
handler.RegisterPublicRoutes(mux) handler.RegisterPublicRoutes(router)
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil) getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
getRecorder := httptest.NewRecorder() getRecorder := httptest.NewRecorder()
mux.ServeHTTP(getRecorder, getRequest) router.ServeHTTP(getRecorder, getRequest)
if getRecorder.Code != http.StatusOK { if getRecorder.Code != http.StatusOK {
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String()) 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) favoriteRequest.AddCookie(cookie)
} }
favoriteRecorder := httptest.NewRecorder() favoriteRecorder := httptest.NewRecorder()
mux.ServeHTTP(favoriteRecorder, favoriteRequest) router.ServeHTTP(favoriteRecorder, favoriteRequest)
if favoriteRecorder.Code != http.StatusOK { if favoriteRecorder.Code != http.StatusOK {
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String()) t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
} }
@@ -34,7 +34,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
} }
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555") 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) t.Fatalf("insert user: %v", err)
} }
repository := NewRepository(database) repository := NewRepository(database)
@@ -62,7 +62,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
FavoritesEnabled: true, FavoritesEnabled: true,
DownloadAllEnabled: true, DownloadAllEnabled: true,
ThemeConfig: json.RawMessage(`{"mode":"dark"}`), ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`), BrandingConfig: json.RawMessage(`{"studioName":"Noah Bianchi"}`),
}) })
if err != nil { if err != nil {
t.Fatalf("update gallery: %v", err) t.Fatalf("update gallery: %v", err)
-73
View File
@@ -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)
}
-125
View File
@@ -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)
}
}
-28
View File
@@ -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"`
}
-97
View File
@@ -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)
@@ -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)
}
}
-33
View File
@@ -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
}
+163 -92
View File
@@ -12,6 +12,7 @@ import (
"github.com/example/sndit/backend/internal/auth" "github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage" "github.com/example/sndit/backend/internal/storage"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "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} return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
} }
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) { func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List))) router.GET("/api/galleries/:id/media", require, h.List)
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload))) router.POST("/api/galleries/:id/uploads", require, h.CreateUpload)
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload))) router.POST("/api/uploads/:id/complete", require, h.CompleteUpload)
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update))) router.PATCH("/api/media/:id", require, h.Update)
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download))) router.POST("/api/media/:id/download", require, h.Download)
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete))) router.DELETE("/api/uploads/:id", require, h.Delete)
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete))) router.DELETE("/api/media/:id", require, h.Delete)
} }
type uploadRequest struct { type uploadRequest struct {
@@ -55,57 +56,82 @@ type updateRequest struct {
SortOrder *int `json:"sortOrder"` SortOrder *int `json:"sortOrder"`
} }
func (h *Handler) List(w http.ResponseWriter, r *http.Request) { // List godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
galleryID, err := parseID(r.PathValue("id")) galleryID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id") writeError(c, http.StatusBadRequest, "invalid gallery id")
return 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 { if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
items, err := h.repository.ListByGallery(r.Context(), galleryID) items, err := h.repository.ListByGallery(c.Request.Context(), galleryID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not load media") writeError(c, http.StatusInternalServerError, "could not load media")
return return
} }
views := make([]Public, 0, len(items)) views := make([]Public, 0, len(items))
for _, item := range 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs") writeError(c, http.StatusInternalServerError, "could not sign media URLs")
return return
} }
views = append(views, view) 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) { // CreateUpload godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
galleryID, err := parseID(r.PathValue("id")) galleryID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id") writeError(c, http.StatusBadRequest, "invalid gallery id")
return 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 { if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found") writeError(c, http.StatusNotFound, "gallery not found")
return return
} }
var request uploadRequest var request uploadRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return return
} }
filename := safeFilename(request.Filename) filename := safeFilename(request.Filename)
@@ -114,145 +140,192 @@ func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
mimeType = mime.TypeByExtension(filepath.Ext(filename)) mimeType = mime.TypeByExtension(filepath.Ext(filename))
} }
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) { if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
writeError(w, http.StatusBadRequest, "unsupported media file") writeError(c, http.StatusBadRequest, "unsupported media file")
return return
} }
if request.FileSize <= 0 || request.FileSize > maxUploadSize { 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 return
} }
mediaID := uuid.New() mediaID := uuid.New()
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename) 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not create upload") writeError(c, http.StatusInternalServerError, "could not create upload")
return 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 { if err != nil {
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID) _, _ = h.repository.Delete(c.Request.Context(), user.ID, mediaID)
writeError(w, http.StatusInternalServerError, "could not create upload URL") writeError(c, http.StatusInternalServerError, "could not create upload URL")
return return
} }
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(c, http.StatusCreated, map[string]any{
"uploadId": mediaID.String(), "uploadId": mediaID.String(),
"uploadUrl": uploadURL, "uploadUrl": uploadURL,
"media": publicFromRecord(item), "media": publicFromRecord(item),
}) })
} }
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) { // CompleteUpload godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
mediaID, err := parseID(r.PathValue("id")) mediaID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return 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 { if err != nil {
writeError(w, http.StatusNotFound, "media not found") writeError(c, http.StatusNotFound, "media not found")
return return
} }
info, err := h.storage.Stat(r.Context(), item.StorageKey) info, err := h.storage.Stat(c.Request.Context(), item.StorageKey)
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "uploaded object is not available yet") writeError(c, http.StatusBadRequest, "uploaded object is not available yet")
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not complete upload") writeError(c, http.StatusInternalServerError, "could not complete upload")
return return
} }
h.processor.Enqueue(item.ID) 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) { // Update godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
mediaID, err := parseID(r.PathValue("id")) mediaID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return return
} }
var request updateRequest var request updateRequest
if !decodeJSON(w, r, &request) { if !decodeJSON(c, &request) {
return return
} }
if request.SortOrder == nil || *request.SortOrder < 0 { 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 return
} }
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil { if err := h.repository.UpdateSortOrder(c.Request.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
writeError(w, http.StatusNotFound, "media not found") writeError(c, http.StatusNotFound, "media not found")
return return
} }
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID) item, _ := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
view, err := h.view(r.Context(), item, true) view, err := h.view(c.Request.Context(), item, true)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URL") writeError(c, http.StatusInternalServerError, "could not sign media URL")
return 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) { // Delete godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
mediaID, err := parseID(r.PathValue("id")) mediaID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return 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 { if err != nil {
writeError(w, http.StatusNotFound, "media not found") writeError(c, http.StatusNotFound, "media not found")
return return
} }
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} { for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
if key != "" && key != item.ExternalURL { 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) { // Download godoc
user, ok := auth.UserFromContext(r.Context()) // @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 { if !ok {
writeError(w, http.StatusUnauthorized, "authentication required") writeError(c, http.StatusUnauthorized, "authentication required")
return return
} }
mediaID, err := parseID(r.PathValue("id")) mediaID, err := parseID(c.Param("id"))
if err != nil { if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id") writeError(c, http.StatusBadRequest, "invalid media id")
return 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 { if err != nil || item.ProcessingStatus != StatusReady {
writeError(w, http.StatusNotFound, "media not found") writeError(c, http.StatusNotFound, "media not found")
return return
} }
url := item.ExternalURL url := item.ExternalURL
if url == "" { 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download") writeError(c, http.StatusInternalServerError, "could not create download")
return return
} }
} }
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String()) _ = h.repository.RecordDownload(c.Request.Context(), item.GalleryID, &item.ID, user.ID.String())
writeJSON(w, http.StatusOK, map[string]string{"url": url}) writeJSON(c, http.StatusOK, map[string]string{"url": url})
} }
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) { 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) return uuid.Parse(value)
} }
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { func decodeJSON(c *gin.Context, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json") writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
return false 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() decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil { if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body") writeError(c, http.StatusBadRequest, "invalid JSON body")
return false return false
} }
return true return true
} }
func writeError(w http.ResponseWriter, status int, message string) { func writeError(c *gin.Context, status int, message string) {
writeJSON(w, status, map[string]string{"error": message}) writeJSON(c, status, map[string]string{"error": message})
} }
func writeJSON(w http.ResponseWriter, status int, value any) { func writeJSON(c *gin.Context, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") c.JSON(status, value)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
} }
+1 -1
View File
@@ -82,7 +82,7 @@ func (s *MinIO) EnsureBucket(ctx context.Context) error {
origins = []string{"*"} origins = []string{"*"}
} }
if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{ if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{
ID: "northline-browser-uploads", ID: "studio-browser-uploads",
AllowedOrigin: origins, AllowedOrigin: origins,
AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"}, AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"},
AllowedHeader: []string{"*"}, AllowedHeader: []string{"*"},
+58 -2
View File
@@ -24,7 +24,7 @@ services:
environment: environment:
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin} MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_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: ports:
- "${MINIO_API_PORT:-9000}:9000" - "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001" - "${MINIO_CONSOLE_PORT:-9001}:9001"
@@ -36,6 +36,62 @@ services:
timeout: 5s timeout: 5s
retries: 10 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: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
+12
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@
name="description" name="description"
content="Beautiful private galleries for photographers and their clients." content="Beautiful private galleries for photographers and their clients."
/> />
<title>Northline Delivery Studio</title> <title>Noah Bianchi Studio</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+26
View File
@@ -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;
}
}
+3 -5
View File
@@ -11,6 +11,7 @@ import GalleriesPage from './pages/dashboard/GalleriesPage';
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage'; import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
import PlaceholderPage from './pages/dashboard/PlaceholderPage'; import PlaceholderPage from './pages/dashboard/PlaceholderPage';
import DevPage from './pages/dashboard/DevPage'; import DevPage from './pages/dashboard/DevPage';
import SettingsPage from './pages/dashboard/SettingsPage';
import NotFoundPage from './pages/NotFoundPage'; import NotFoundPage from './pages/NotFoundPage';
export default function App() { export default function App() {
@@ -67,7 +68,7 @@ export default function App() {
<RequireAuth> <RequireAuth>
<PlaceholderPage <PlaceholderPage
title="Storage" title="Storage"
description="A calm view of every original, preview, and byte in your studio is on its way." description="Storage module unavailable."
/> />
</RequireAuth> </RequireAuth>
} }
@@ -76,10 +77,7 @@ export default function App() {
path="/dashboard/settings" path="/dashboard/settings"
element={ element={
<RequireAuth> <RequireAuth>
<PlaceholderPage <SettingsPage />
title="Settings"
description="Your studio identity and delivery defaults will have a home here soon."
/>
</RequireAuth> </RequireAuth>
} }
/> />
+4 -8
View File
@@ -6,16 +6,12 @@ export function AuthLayout({ children }: { children: ReactNode }) {
<div className="auth-shell__texture" aria-hidden="true" /> <div className="auth-shell__texture" aria-hidden="true" />
<div className="auth-shell__brand"> <div className="auth-shell__brand">
<span className="platform-mark">N</span> <span className="platform-mark">N</span>
<span>Northline</span> <span>Noah Bianchi</span>
</div> </div>
<div className="auth-shell__aside"> <div className="auth-shell__aside">
<p className="platform-kicker">The work deserves a beautiful handoff.</p> <p className="platform-kicker">Private delivery system</p>
<h1 className="platform-display"> <h1 className="platform-display">Studio Access</h1>
Deliver the <p>Photographer gallery console for client delivery.</p>
<br />
<em>feeling.</em>
</h1>
<p>Private galleries for the photographs people keep forever.</p>
</div> </div>
<div className="auth-shell__panel">{children}</div> <div className="auth-shell__panel">{children}</div>
</div> </div>
@@ -26,8 +26,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<Link className="studio-logo" to="/dashboard"> <Link className="studio-logo" to="/dashboard">
<span className="studio-logo__mark">N</span> <span className="studio-logo__mark">N</span>
<span> <span>
Northline Noah Bianchi
<small>delivery studio</small> <small>photography studio</small>
</span> </span>
</Link> </Link>
<div className="studio-sidebar__label">Workspace</div> <div className="studio-sidebar__label">Workspace</div>
@@ -48,8 +48,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<div className="studio-sidebar__note"> <div className="studio-sidebar__note">
<span className="studio-sidebar__note-mark">+</span> <span className="studio-sidebar__note-mark">+</span>
<span> <span>
<strong>Make it memorable.</strong> <strong>System Status</strong>
<small>Your work deserves a proper handoff.</small> <small>Delivery console active.</small>
</span> </span>
</div> </div>
<div className="studio-account"> <div className="studio-account">
@@ -73,7 +73,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<div className="studio-mobilebar"> <div className="studio-mobilebar">
<Link className="studio-logo" to="/dashboard"> <Link className="studio-logo" to="/dashboard">
<span className="studio-logo__mark">N</span> <span className="studio-logo__mark">N</span>
<span>Northline</span> <span>Noah Bianchi</span>
</Link> </Link>
<button <button
type="button" type="button"
@@ -65,7 +65,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
}); });
try { try {
window.localStorage.setItem( window.localStorage.setItem(
'northline:last-upload-error', 'studio:last-upload-error',
JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }), JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }),
); );
} catch { } catch {
@@ -117,7 +117,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
<span className="upload-zone__orb" aria-hidden="true"> <span className="upload-zone__orb" aria-hidden="true">
+ +
</span> </span>
<span className="upload-zone__title">Drop finished work here</span> <span className="upload-zone__title">Drop files here</span>
<span className="upload-zone__hint"> <span className="upload-zone__hint">
or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV
</span> </span>
@@ -23,7 +23,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
const cover = gallery.cover || photos[0]; const cover = gallery.cover || photos[0];
const theme = gallery.themeConfig || {}; const theme = gallery.themeConfig || {};
const branding = gallery.brandingConfig || {}; const branding = gallery.brandingConfig || {};
const style = { '--gallery-accent': theme.accent || '#a85e55' } as CSSProperties; const style = { '--gallery-accent': theme.accent || '#bc6655' } as CSSProperties;
useEffect(() => setItems(gallery.media), [gallery.media]); useEffect(() => setItems(gallery.media), [gallery.media]);
@@ -109,8 +109,8 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
> >
{gallery.preview && ( {gallery.preview && (
<div className="preview-ribbon"> <div className="preview-ribbon">
<span>Preview mode</span> <span>Preview Mode</span>
<span>This is how your clients will see it</span> <span>Client View</span>
</div> </div>
)} )}
<header className="client-gallery__nav"> <header className="client-gallery__nav">
@@ -146,7 +146,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
<main> <main>
<section className="client-hero"> <section className="client-hero">
<div className="client-hero__copy"> <div className="client-hero__copy">
<p className="client-kicker">A collection for {gallery.clientName}</p> <p className="client-kicker">Gallery for {gallery.clientName}</p>
<h1 className="client-display">{gallery.title}</h1> <h1 className="client-display">{gallery.title}</h1>
{gallery.description && ( {gallery.description && (
<p className="client-hero__description">{gallery.description}</p> <p className="client-hero__description">{gallery.description}</p>
@@ -161,21 +161,21 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
</div> </div>
)} )}
<div className="client-hero__cover-caption"> <div className="client-hero__cover-caption">
<span>Open to remember</span> <span>Cover Image</span>
<span>{items.length} pieces</span> <span>{items.length} pieces</span>
</div> </div>
</div> </div>
<div className="client-hero__scroll"> <div className="client-hero__scroll">
<span /> Scroll to wander <span /> Scroll for media
</div> </div>
</section> </section>
<section className="client-work-section"> <section className="client-work-section">
<div className="client-section-heading"> <div className="client-section-heading">
<div> <div>
<p className="client-kicker">The collection</p> <p className="client-kicker">Gallery Media</p>
<h2 className="client-display"> <h2 className="client-display">
The day, <em>held still.</em> Photos <em>&amp; video.</em>
</h2> </h2>
</div> </div>
<span> <span>
@@ -186,7 +186,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
{items.length === 0 ? ( {items.length === 0 ? (
<div className="client-empty"> <div className="client-empty">
<span>+</span> <span>+</span>
<p>Your gallery is still being arranged.</p> <p>No media uploaded.</p>
</div> </div>
) : ( ) : (
<div className="client-media-grid"> <div className="client-media-grid">
@@ -206,13 +206,13 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
<section className="client-download-section"> <section className="client-download-section">
<div className="client-download-section__copy"> <div className="client-download-section__copy">
<p className="client-kicker">Take it with you</p> <p className="client-kicker">Downloads</p>
<h2 className="client-display"> <h2 className="client-display">
The moments are Download
<br /> <br />
<em>yours to keep.</em> <em>originals.</em>
</h2> </h2>
<p>Save the full-resolution photographs and revisit this chapter whenever you like.</p> <p>Download the original files from this gallery.</p>
</div> </div>
{gallery.downloadsEnabled !== false && ( {gallery.downloadsEnabled !== false && (
<button <button
@@ -238,7 +238,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
Your ZIP is ready <span aria-hidden="true">&#8599;</span> ZIP ready <span aria-hidden="true">&#8599;</span>
</a> </a>
)} )}
{downloadJob?.status === 'FAILED' && ( {downloadJob?.status === 'FAILED' && (
@@ -254,7 +254,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span> <span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
<div> <div>
<strong>{branding.studioName || 'Your studio'}</strong> <strong>{branding.studioName || 'Your studio'}</strong>
<small>{branding.tagline || 'Photographs for keeps.'}</small> <small>{branding.tagline || 'Client Delivery'}</small>
</div> </div>
</div> </div>
<div className="client-footer__links"> <div className="client-footer__links">
@@ -269,7 +269,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
</a> </a>
)} )}
</div> </div>
<span className="client-footer__credit">Delivered with intention</span> <span className="client-footer__credit">Noah Bianchi</span>
</footer> </footer>
<AnimatePresence> <AnimatePresence>
@@ -27,7 +27,6 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
return ( return (
<div className="client-lock-screen"> <div className="client-lock-screen">
<div className="client-lock-screen__glow" aria-hidden="true" />
<div className="client-lock-screen__brand"> <div className="client-lock-screen__brand">
<span className="client-monogram"> <span className="client-monogram">
{gallery.brandingConfig.studioName?.slice(0, 1) || 'N'} {gallery.brandingConfig.studioName?.slice(0, 1) || 'N'}
@@ -38,9 +37,9 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
<span className="client-lock-screen__lock" aria-hidden="true"> <span className="client-lock-screen__lock" aria-hidden="true">
+ +
</span> </span>
<p className="client-kicker">A private delivery</p> <p className="client-kicker">Protected Gallery</p>
<h1 className="client-display">{gallery.title}</h1> <h1 className="client-display">{gallery.title}</h1>
<p>This gallery was made for {gallery.clientName}. Enter the password to open it.</p> <p>Password required for {gallery.clientName}.</p>
<form className="client-password-form" onSubmit={submit}> <form className="client-password-form" onSubmit={submit}>
<label className="sr-only" htmlFor="gallery-password"> <label className="sr-only" htmlFor="gallery-password">
Gallery password Gallery password
@@ -65,7 +64,7 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
</form> </form>
{error && <p className="client-form-error">{error}</p>} {error && <p className="client-form-error">{error}</p>}
</div> </div>
<p className="client-lock-screen__footer">The work is waiting inside.</p> <p className="client-lock-screen__footer">Access required.</p>
</div> </div>
); );
} }
@@ -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 (
<div className={`gift-background gift-background--${tone}`}>
<div className="gift-background__wash" aria-hidden="true" />
<div className="gift-background__orb gift-background__orb--one" aria-hidden="true" />
<div className="gift-background__orb gift-background__orb--two" aria-hidden="true" />
<div className="gift-background__grid" aria-hidden="true" />
<div className="gift-background__noise" aria-hidden="true" />
<main className="gift-background__content">{children}</main>
</div>
);
}
-36
View File
@@ -1,36 +0,0 @@
import type { CSSProperties } from 'react';
const pieces = [
{ left: 8, top: -6, size: 9, delay: 0, color: '#ed9a79', rotation: 18 },
{ left: 19, top: 6, size: 6, delay: 0.45, color: '#f5d58d', rotation: 72 },
{ left: 31, top: -12, size: 8, delay: 0.9, color: '#b7a5e5', rotation: 38 },
{ left: 46, top: 3, size: 5, delay: 0.2, color: '#f6eee2', rotation: 92 },
{ left: 61, top: -9, size: 10, delay: 0.7, color: '#df8e9b', rotation: 140 },
{ left: 75, top: 4, size: 6, delay: 0.1, color: '#f5d58d', rotation: 210 },
{ left: 89, top: -14, size: 8, delay: 0.58, color: '#9fb7cc', rotation: 265 },
{ left: 13, top: 18, size: 5, delay: 1.1, color: '#f6eee2', rotation: 305 },
{ left: 39, top: 13, size: 7, delay: 0.32, color: '#df8e9b', rotation: 180 },
{ left: 68, top: 17, size: 5, delay: 0.8, color: '#b7a5e5', rotation: 18 },
{ left: 83, top: 20, size: 9, delay: 1.28, color: '#ed9a79', rotation: 122 },
{ left: 54, top: -18, size: 4, delay: 1.45, color: '#f6eee2', rotation: 245 },
];
export function Confetti() {
return (
<div className="confetti" aria-hidden="true">
{pieces.map((piece, index) => {
const style = {
left: `${piece.left}%`,
top: `${piece.top}%`,
width: `${piece.size}px`,
height: `${piece.size * 2.1}px`,
backgroundColor: piece.color,
animationDelay: `${piece.delay}s`,
transform: `rotate(${piece.rotation}deg)`,
} satisfies CSSProperties;
return <span className="confetti__piece" key={index} style={style} />;
})}
</div>
);
}
@@ -1,217 +0,0 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { Gift } from '../../types/gift';
import { Background } from './Background';
import { Confetti } from './Confetti';
import { GiftItem } from './GiftItem';
import { IntroScreen } from './IntroScreen';
import { ProgressIndicator } from './ProgressIndicator';
import { RevealScreen } from './RevealScreen';
type ExperienceStage = 'INTRO' | 'OPEN_PROMPT' | 'REVEAL' | 'CONTENT' | 'FINISH';
interface GiftExperienceProps {
gift: Gift;
}
const screenTransition = {
duration: 0.65,
ease: [0.22, 1, 0.36, 1] as const,
};
export function GiftExperience({ gift }: GiftExperienceProps) {
const [stage, setStage] = useState<ExperienceStage>('INTRO');
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
if (stage !== 'REVEAL') {
return undefined;
}
const timer = window.setTimeout(() => {
setStage(gift.items.length > 0 ? 'CONTENT' : 'FINISH');
}, 1550);
return () => window.clearTimeout(timer);
}, [gift.items.length, stage]);
function showNextItem() {
if (activeIndex < gift.items.length - 1) {
setActiveIndex((index) => index + 1);
return;
}
setStage('FINISH');
}
function replay() {
setActiveIndex(0);
setStage('INTRO');
}
return (
<div className="gift-experience" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
{stage === 'INTRO' && (
<motion.div
className="experience-screen"
key="intro"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<IntroScreen gift={gift} onContinue={() => setStage('OPEN_PROMPT')} />
</motion.div>
)}
{stage === 'OPEN_PROMPT' && (
<motion.div
className="experience-screen"
key="open-prompt"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<RevealScreen
recipientName={gift.recipientName}
isRevealing={false}
onReveal={() => setStage('REVEAL')}
/>
</motion.div>
)}
{stage === 'REVEAL' && (
<motion.div
className="experience-screen"
key="reveal"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={screenTransition}
>
<RevealScreen
recipientName={gift.recipientName}
isRevealing
onReveal={() => undefined}
/>
</motion.div>
)}
{stage === 'CONTENT' && (
<motion.div
className="experience-screen"
key="content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<Background tone="content">
<div className="content-screen">
<div className="content-screen__topline">
<span className="brand-lockup brand-lockup--ink">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">a few things for you</span>
</div>
<div className="content-screen__intro">
<p className="eyebrow eyebrow--ink">chapter {activeIndex + 1}</p>
<h1 className="display">
For the moments
<br />
<em>worth keeping.</em>
</h1>
</div>
<ProgressIndicator current={activeIndex} total={gift.items.length} />
<AnimatePresence mode="wait" initial={false}>
{gift.items[activeIndex] && (
<motion.div
className="content-screen__item"
key={gift.items[activeIndex].id}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<GiftItem item={gift.items[activeIndex]} />
</motion.div>
)}
</AnimatePresence>
<div className="content-screen__bottomline">
<span>for {gift.recipientName}</span>
<button className="next-action" type="button" onClick={showNextItem}>
<span>
{activeIndex === gift.items.length - 1 ? 'Keep this moment' : 'Next memory'}
</span>
<span aria-hidden="true">&#8599;</span>
</button>
</div>
</div>
</Background>
</motion.div>
)}
{stage === 'FINISH' && (
<motion.div
className="experience-screen"
key="finish"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={screenTransition}
>
<Background tone="finish">
<Confetti />
<div className="finish-screen">
<div className="finish-screen__topline">
<span className="brand-lockup brand-lockup--light">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">the end, for now</span>
</div>
<div className="finish-screen__center">
<motion.div
className="finish-screen__spark"
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ delay: 0.25, type: 'spring', stiffness: 180, damping: 12 }}
>
<span aria-hidden="true">&#10022;</span>
</motion.div>
<p className="eyebrow eyebrow--quiet">a final note</p>
<h1 className="display">
Made with love,
<br />
<em>{gift.recipientName}.</em>
</h1>
<p className="finish-screen__message">{gift.revealMessage}</p>
<p className="finish-screen__signature">
Always,
<br />
<strong>{gift.senderName}</strong>
</p>
</div>
<button
className="ghost-action ghost-action--light finish-screen__replay"
type="button"
onClick={replay}
>
<span>See it again</span>
<span aria-hidden="true">&#8599;</span>
</button>
</div>
</Background>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
import { ImageItem } from './ImageItem';
import { TextItem } from './TextItem';
import { VideoItem } from './VideoItem';
interface GiftItemProps {
item: GiftItemData;
}
export function GiftItem({ item }: GiftItemProps) {
switch (item.type.toLowerCase()) {
case 'image':
case 'photo':
return <ImageItem item={item} />;
case 'video':
return <VideoItem item={item} />;
case 'text':
case 'note':
case 'card':
case 'link':
default:
return <TextItem item={item} />;
}
}
@@ -1,36 +0,0 @@
import { useState } from 'react';
import type { GiftItem as GiftItemData } from '../../types/gift';
interface ImageItemProps {
item: GiftItemData;
}
export function ImageItem({ item }: ImageItemProps) {
const [hasError, setHasError] = useState(false);
return (
<div className="gift-image-item">
<div className="gift-image-item__frame">
{item.mediaUrl && !hasError ? (
<img
src={item.mediaUrl}
alt={item.title || 'A memory from your gift'}
onError={() => setHasError(true)}
/>
) : (
<div className="gift-image-item__placeholder">
<span className="placeholder-sun" aria-hidden="true" />
<span>{hasError ? 'A memory for you' : 'Your image goes here'}</span>
</div>
)}
{(item.title || item.text) && (
<div className="gift-image-item__caption">
{item.title && <strong>{item.title}</strong>}
{item.text && <span>{item.text}</span>}
</div>
)}
</div>
</div>
);
}
@@ -1,90 +0,0 @@
import { motion } from 'framer-motion';
import type { Gift } from '../../types/gift';
import { Background } from './Background';
interface IntroScreenProps {
gift: Gift;
onContinue: () => void;
}
export function IntroScreen({ gift, onContinue }: IntroScreenProps) {
return (
<Background tone="intro">
<div className="intro-screen">
<motion.div
className="intro-screen__topline"
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1 }}
>
<span className="brand-lockup">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">A private little moment</span>
</motion.div>
<div className="intro-screen__center">
<motion.p
className="eyebrow eyebrow--warm"
initial={{ opacity: 0, letterSpacing: '0.28em' }}
animate={{ opacity: 1, letterSpacing: '0.18em' }}
transition={{ duration: 0.9, delay: 0.3 }}
>
{gift.title}
</motion.p>
<motion.h1
className="display intro-screen__title"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
Hey <em>{gift.recipientName}.</em>
</motion.h1>
<motion.p
className="intro-screen__message"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.65 }}
>
{gift.introMessage}
</motion.p>
<motion.button
className="primary-action"
type="button"
onClick={onContinue}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.85 }}
whileHover={{ y: -3 }}
whileTap={{ scale: 0.98 }}
>
<span>Open your surprise</span>
<span className="primary-action__icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<path
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
</span>
</motion.button>
</div>
<motion.div
className="intro-screen__footer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 1.1 }}
>
<span>made with intention</span>
<span>
from <strong>{gift.senderName}</strong>
</span>
</motion.div>
</div>
</Background>
);
}
@@ -1,22 +0,0 @@
interface ProgressIndicatorProps {
current: number;
total: number;
}
export function ProgressIndicator({ current, total }: ProgressIndicatorProps) {
return (
<div className="progress-indicator" aria-label={`Memory ${current + 1} of ${total}`}>
<div className="progress-indicator__track" aria-hidden="true">
{Array.from({ length: total }, (_, index) => (
<span
className={`progress-indicator__segment ${index <= current ? 'is-active' : ''}`}
key={index}
/>
))}
</div>
<span className="progress-indicator__count">
{String(current + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
</span>
</div>
);
}
@@ -1,90 +0,0 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Background } from './Background';
interface RevealScreenProps {
recipientName: string;
isRevealing: boolean;
onReveal: () => void;
}
export function RevealScreen({ recipientName, isRevealing, onReveal }: RevealScreenProps) {
return (
<Background tone="reveal">
<div className="reveal-screen">
<div className="reveal-screen__topline">
<span className="brand-lockup brand-lockup--light">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">just for {recipientName}</span>
</div>
<div className="reveal-screen__center">
<motion.div
className="reveal-orbit reveal-orbit--outer"
animate={isRevealing ? { rotate: 360, scale: 1.2 } : { rotate: 0, scale: 1 }}
transition={{ duration: 1.5, ease: 'easeInOut' }}
/>
<motion.div
className="reveal-orbit reveal-orbit--inner"
animate={isRevealing ? { rotate: -360, scale: 0.76 } : { rotate: 0, scale: 1 }}
transition={{ duration: 1.35, ease: 'easeInOut' }}
/>
<motion.div
className="reveal-seal"
animate={
isRevealing
? { scale: [1, 1.06, 0.2], opacity: [1, 1, 0], rotate: [0, -8, 18] }
: { scale: 1, opacity: 1, rotate: 0 }
}
transition={{ duration: 1.25, times: [0, 0.48, 1], ease: [0.22, 1, 0.36, 1] }}
>
<span className="reveal-seal__halo" />
<span className="reveal-seal__mark">ls</span>
<span className="reveal-seal__label">open gently</span>
</motion.div>
<AnimatePresence mode="wait">
<motion.div
className="reveal-screen__copy"
key={isRevealing ? 'revealing' : 'prompt'}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.35 }}
>
<p className="eyebrow eyebrow--quiet">
{isRevealing ? 'here it comes' : 'one tiny step'}
</p>
<h1 className="display">
{isRevealing ? 'Making room for a little magic.' : 'There is something inside.'}
</h1>
<p>
{isRevealing
? 'Take a breath. Your moment is opening.'
: 'No rush. This one was made to be opened slowly.'}
</p>
</motion.div>
</AnimatePresence>
{!isRevealing && (
<motion.button
className="ghost-action ghost-action--light"
type="button"
onClick={onReveal}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
>
<span>Unwrap the moment</span>
<span aria-hidden="true">&#8599;</span>
</motion.button>
)}
</div>
</div>
</Background>
);
}
-25
View File
@@ -1,25 +0,0 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
interface TextItemProps {
item: GiftItemData;
}
export function TextItem({ item }: TextItemProps) {
const eyebrow =
typeof item.metadata?.eyebrow === 'string' ? item.metadata.eyebrow : 'A note from me';
return (
<div className="gift-text-item">
<div className="gift-text-item__quote" aria-hidden="true">
&ldquo;
</div>
<p className="eyebrow eyebrow--ink">{eyebrow}</p>
{item.title && <h2 className="display">{item.title}</h2>}
<p className="gift-text-item__body">{item.text || 'A little note, just because.'}</p>
<div className="gift-text-item__signature" aria-hidden="true">
<span />
<span>with love</span>
</div>
</div>
);
}
@@ -1,31 +0,0 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
interface VideoItemProps {
item: GiftItemData;
}
export function VideoItem({ item }: VideoItemProps) {
return (
<div className="gift-video-item">
{item.mediaUrl ? (
<video
controls
playsInline
preload="metadata"
poster={item.metadata?.poster as string | undefined}
>
<source src={item.mediaUrl} />
Your browser does not support video playback.
</video>
) : (
<div className="gift-video-item__placeholder">
<span className="video-play" aria-hidden="true">
&#9654;
</span>
<span>Your video goes here</span>
</div>
)}
{item.text && <p>{item.text}</p>}
</div>
);
}
+15 -26
View File
@@ -1,4 +1,3 @@
import type { Gift } from '../types/gift';
import type { DevDiagnostics, DevStorageCheck } from '../types/dev'; import type { DevDiagnostics, DevStorageCheck } from '../types/dev';
import type { import type {
DownloadJob, DownloadJob,
@@ -54,31 +53,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
return (await response.json()) as T; return (await response.json()) as T;
} }
export async function getGift(slug: string, signal?: AbortSignal): Promise<Gift> {
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/api/gifts/${encodeURIComponent(slug)}`, { signal });
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
throw new ApiError(
'The surprise could not be reached. Check your connection and try again.',
0,
);
}
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: unknown } | null;
const message =
typeof payload?.error === 'string' ? payload.error : 'This gift is not available.';
throw new ApiError(message, response.status);
}
return (await response.json()) as Gift;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -110,6 +84,21 @@ export async function logout(): Promise<void> {
await request('/api/auth/logout', { method: 'POST' }); await request('/api/auth/logout', { method: 'POST' });
} }
export async function updateProfile(name: string, email: string): Promise<User> {
const response = await request<{ user: User }>('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ name, email }),
});
return response.user;
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
await request('/api/auth/password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
}
export async function getGalleries(): Promise<GallerySummary[]> { export async function getGalleries(): Promise<GallerySummary[]> {
const response = await request<{ galleries: GallerySummary[] }>('/api/galleries'); const response = await request<{ galleries: GallerySummary[] }>('/api/galleries');
return response.galleries; return response.galleries;
-88
View File
@@ -1,88 +0,0 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Background } from '../components/gift/Background';
import { GiftExperience } from '../components/gift/GiftExperience';
import { getGift } from '../lib/api';
import type { Gift } from '../types/gift';
type GiftRequestState =
{ status: 'loading' } | { status: 'success'; gift: Gift } | { status: 'error'; message: string };
export default function GiftPage() {
const { slug } = useParams<{ slug: string }>();
const [attempt, setAttempt] = useState(0);
const [request, setRequest] = useState<GiftRequestState>({ status: 'loading' });
useEffect(() => {
if (!slug) {
return;
}
const controller = new AbortController();
setRequest({ status: 'loading' });
getGift(slug, controller.signal)
.then((gift) => setRequest({ status: 'success', gift }))
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setRequest({
status: 'error',
message: error instanceof Error ? error.message : 'This gift could not be opened.',
});
});
return () => controller.abort();
}, [attempt, slug]);
if (request.status === 'success') {
return <GiftExperience gift={request.gift} />;
}
if (request.status === 'error') {
return (
<Background tone="intro">
<div className="request-state">
<p className="eyebrow eyebrow--warm">a small hiccup</p>
<h1 className="display">This moment is hiding.</h1>
<p>{request.message}</p>
<button
className="primary-action"
type="button"
onClick={() => setAttempt((value) => value + 1)}
>
<span>Try again</span>
<span className="primary-action__icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<path
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
</span>
</button>
</div>
</Background>
);
}
return (
<Background tone="intro">
<div
className="request-state request-state--loading"
aria-busy="true"
aria-label="Loading your gift"
>
<span className="loading-mark" aria-hidden="true">
ls
</span>
<p className="eyebrow eyebrow--warm">opening something special</p>
<h1 className="display">Just a moment.</h1>
<span className="loading-line" aria-hidden="true" />
</div>
</Background>
);
}
+7 -11
View File
@@ -34,17 +34,13 @@ export default function LoginPage() {
<AuthLayout> <AuthLayout>
<div className="auth-card"> <div className="auth-card">
<div className="auth-card__heading"> <div className="auth-card__heading">
<p className="platform-kicker platform-kicker--accent">Welcome back</p> <p className="platform-kicker platform-kicker--accent">01 / Authenticate</p>
<h2 className="platform-display"> <h2 className="platform-display">Sign In</h2>
Your work, <p>Photographer console access.</p>
<br />
<em>waiting.</em>
</h2>
<p>Sign in to keep shaping the way your clients experience their photographs.</p>
</div> </div>
<form className="auth-form" onSubmit={submit}> <form className="auth-form" onSubmit={submit}>
<label> <label>
Email address Email:
<input <input
type="email" type="email"
autoComplete="email" autoComplete="email"
@@ -54,7 +50,7 @@ export default function LoginPage() {
/> />
</label> </label>
<label> <label>
Password Password:
<input <input
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
@@ -69,12 +65,12 @@ export default function LoginPage() {
type="submit" type="submit"
disabled={submitting} disabled={submitting}
> >
<span>{submitting ? 'Opening studio...' : 'Sign in'}</span> <span>{submitting ? 'Signing in...' : 'Sign In'}</span>
<span aria-hidden="true">&#8599;</span> <span aria-hidden="true">&#8599;</span>
</button> </button>
</form> </form>
<p className="auth-card__footer"> <p className="auth-card__footer">
New to Northline? <Link to="/register">Create an account</Link> No studio access? <Link to="/register">New account</Link>
</p> </p>
</div> </div>
</AuthLayout> </AuthLayout>
+4 -4
View File
@@ -4,13 +4,13 @@ export default function NotFoundPage() {
return ( return (
<div className="client-error client-error--not-found"> <div className="client-error client-error--not-found">
<span className="client-loading__mark">N</span> <span className="client-loading__mark">N</span>
<p className="client-kicker">Nothing here yet</p> <p className="client-kicker">404 / Gallery Not Found</p>
<h1 className="client-display"> <h1 className="client-display">
This link took Gallery
<br /> <br />
<em>a wrong turn.</em> <em>not found.</em>
</h1> </h1>
<p>The gallery you are looking for may have moved or never existed.</p> <p>The requested gallery does not exist or is no longer available.</p>
<Link className="client-download-ready" to="/login"> <Link className="client-download-ready" to="/login">
Return to studio <span aria-hidden="true">&#8599;</span> Return to studio <span aria-hidden="true">&#8599;</span>
</Link> </Link>
+4 -4
View File
@@ -44,7 +44,7 @@ export function ClientLoading() {
return ( return (
<div className="client-loading"> <div className="client-loading">
<span className="client-loading__mark">N</span> <span className="client-loading__mark">N</span>
<p>Preparing your gallery</p> <p>Loading gallery...</p>
<span className="client-loading__line" /> <span className="client-loading__line" />
</div> </div>
); );
@@ -54,11 +54,11 @@ export function ClientError({ message }: { message: string }) {
return ( return (
<div className="client-error"> <div className="client-error">
<span className="client-loading__mark">N</span> <span className="client-loading__mark">N</span>
<p className="client-kicker">A quiet moment</p> <p className="client-kicker">Gallery Error</p>
<h1 className="client-display"> <h1 className="client-display">
This gallery is Unable to load
<br /> <br />
<em>out of reach.</em> <em>gallery.</em>
</h1> </h1>
<p>{message}</p> <p>{message}</p>
</div> </div>
+8 -12
View File
@@ -33,17 +33,13 @@ export default function RegisterPage() {
<AuthLayout> <AuthLayout>
<div className="auth-card"> <div className="auth-card">
<div className="auth-card__heading"> <div className="auth-card__heading">
<p className="platform-kicker platform-kicker--accent">Start your studio</p> <p className="platform-kicker platform-kicker--accent">01 / Provision</p>
<h2 className="platform-display"> <h2 className="platform-display">New Studio</h2>
Make the handoff <p>Create a private delivery workspace.</p>
<br />
<em>matter.</em>
</h2>
<p>Create a private home for the work your clients have been waiting to see.</p>
</div> </div>
<form className="auth-form" onSubmit={submit}> <form className="auth-form" onSubmit={submit}>
<label> <label>
Studio or photographer name Studio name:
<input <input
autoComplete="name" autoComplete="name"
value={name} value={name}
@@ -52,7 +48,7 @@ export default function RegisterPage() {
/> />
</label> </label>
<label> <label>
Email address Email:
<input <input
type="email" type="email"
autoComplete="email" autoComplete="email"
@@ -62,7 +58,7 @@ export default function RegisterPage() {
/> />
</label> </label>
<label> <label>
Password Password:
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
@@ -78,12 +74,12 @@ export default function RegisterPage() {
type="submit" type="submit"
disabled={submitting} disabled={submitting}
> >
<span>{submitting ? 'Creating studio...' : 'Create account'}</span> <span>{submitting ? 'Creating account...' : 'Create Account'}</span>
<span aria-hidden="true">&#8599;</span> <span aria-hidden="true">&#8599;</span>
</button> </button>
</form> </form>
<p className="auth-card__footer"> <p className="auth-card__footer">
Already have an account? <Link to="/login">Sign in</Link> Existing access? <Link to="/login">Sign in</Link>
</p> </p>
</div> </div>
</AuthLayout> </AuthLayout>
+16 -20
View File
@@ -43,43 +43,39 @@ export default function DashboardPage() {
<div className="studio-page"> <div className="studio-page">
<header className="studio-page__header"> <header className="studio-page__header">
<div> <div>
<p className="studio-kicker">{user?.name || 'Studio'} / overview</p> <p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
<h1 className="studio-display"> <h1 className="studio-display">Dashboard</h1>
Make the handoff <em>matter.</em> <p className="studio-page__lede">Active delivery workspace.</p>
</h1>
<p className="studio-page__lede">
Everything your clients need, in one beautiful place.
</p>
</div> </div>
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new"> <Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
<span>New gallery</span> <span>New Gallery</span>
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</Link> </Link>
</header> </header>
<section className="studio-stats" aria-label="Studio overview"> <section className="studio-stats" aria-label="Studio overview">
<StudioStat <StudioStat
label="Galleries" label="Total Galleries"
value={String(galleries.length).padStart(2, '0')} value={String(galleries.length).padStart(2, '0')}
note="All your work, in one place" note="Gallery Buffer"
accent="coral" accent="coral"
/> />
<StudioStat <StudioStat
label="Published" label="Published"
value={String(published).padStart(2, '0')} value={String(published).padStart(2, '0')}
note="Currently out in the world" note="Live Deliveries"
accent="violet" accent="violet"
/> />
<StudioStat <StudioStat
label="Photographs" label="Photographs"
value={String(photos).padStart(2, '0')} value={String(photos).padStart(2, '0')}
note="Ready to be remembered" note="Media Records"
accent="gold" accent="gold"
/> />
<StudioStat <StudioStat
label="Storage used" label="Storage Used"
value={formatBytes(storage)} value={formatBytes(storage)}
note="Across every gallery" note="Object Storage"
accent="ink" accent="ink"
/> />
</section> </section>
@@ -87,11 +83,11 @@ export default function DashboardPage() {
<section className="studio-section studio-section--recent"> <section className="studio-section studio-section--recent">
<div className="studio-section__heading"> <div className="studio-section__heading">
<div> <div>
<p className="studio-kicker">Your latest work</p> <p className="studio-kicker">06 / Recent activity</p>
<h2 className="studio-heading">Recent galleries</h2> <h2 className="studio-heading">Recent Galleries</h2>
</div> </div>
<Link className="inline-link" to="/dashboard/galleries"> <Link className="inline-link" to="/dashboard/galleries">
View all galleries <span aria-hidden="true">&#8599;</span> View All <span aria-hidden="true">&#8599;</span>
</Link> </Link>
</div> </div>
{error && <p className="studio-alert studio-alert--error">{error}</p>} {error && <p className="studio-alert studio-alert--error">{error}</p>}
@@ -105,11 +101,11 @@ export default function DashboardPage() {
<div className="studio-empty"> <div className="studio-empty">
<span className="studio-empty__mark">+</span> <span className="studio-empty__mark">+</span>
<div> <div>
<h3>Your first gallery starts here.</h3> <h3>No galleries yet</h3>
<p>Give finished work a place that feels as considered as the work itself.</p> <p>No galleries. Create one to start.</p>
</div> </div>
<Link className="inline-link" to="/dashboard/galleries/new"> <Link className="inline-link" to="/dashboard/galleries/new">
Create a gallery <span aria-hidden="true">&#8599;</span> Create Gallery <span aria-hidden="true">&#8599;</span>
</Link> </Link>
</div> </div>
) : ( ) : (
+4 -8
View File
@@ -33,7 +33,7 @@ export default function DevPage() {
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
try { try {
const raw = window.localStorage.getItem('northline:last-upload-error'); const raw = window.localStorage.getItem('studio:last-upload-error');
if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog); if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog);
} catch { } catch {
setUploadError(null); setUploadError(null);
@@ -70,12 +70,8 @@ export default function DevPage() {
<header className="studio-page__header studio-page__header--compact"> <header className="studio-page__header studio-page__header--compact">
<div> <div>
<p className="studio-kicker">Workspace / diagnostics</p> <p className="studio-kicker">Workspace / diagnostics</p>
<h1 className="studio-display"> <h1 className="studio-display">Dev Console</h1>
The <em>workbench.</em> <p className="studio-page__lede">Local runtime diagnostics and storage checks.</p>
</h1>
<p className="studio-page__lede">
Useful, non-secret signals for local development and upload debugging.
</p>
</div> </div>
<div className="dev-page__actions"> <div className="dev-page__actions">
<button <button
@@ -218,7 +214,7 @@ export default function DevPage() {
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
window.localStorage.removeItem('northline:last-upload-error'); window.localStorage.removeItem('studio:last-upload-error');
setUploadError(null); setUploadError(null);
}} }}
> >
+7 -13
View File
@@ -35,16 +35,12 @@ export default function GalleriesPage() {
<div className="studio-page"> <div className="studio-page">
<header className="studio-page__header studio-page__header--compact"> <header className="studio-page__header studio-page__header--compact">
<div> <div>
<p className="studio-kicker">Workspace / library</p> <p className="studio-kicker">Workspace / Galleries</p>
<h1 className="studio-display"> <h1 className="studio-display">Galleries</h1>
Your <em>galleries.</em> <p className="studio-page__lede">All delivery records.</p>
</h1>
<p className="studio-page__lede">
The places where your finished work becomes a shared memory.
</p>
</div> </div>
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new"> <Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
<span>New gallery</span> <span>New Gallery</span>
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</Link> </Link>
</header> </header>
@@ -59,13 +55,11 @@ export default function GalleriesPage() {
<div className="studio-empty studio-empty--large"> <div className="studio-empty studio-empty--large">
<span className="studio-empty__mark">+</span> <span className="studio-empty__mark">+</span>
<div> <div>
<h3>A quiet room, waiting.</h3> <h3>No galleries yet</h3>
<p> <p>No galleries. Create one to start.</p>
Create a gallery and give your next client a delivery experience they will remember.
</p>
</div> </div>
<Link className="platform-button platform-button--dark" to="/dashboard/galleries/new"> <Link className="platform-button platform-button--dark" to="/dashboard/galleries/new">
<span>Create your first gallery</span> <span>Create Gallery</span>
<span aria-hidden="true">&#8599;</span> <span aria-hidden="true">&#8599;</span>
</Link> </Link>
</div> </div>
@@ -284,7 +284,7 @@ export default function GalleryEditorPage() {
<h1 className="studio-display"> <h1 className="studio-display">
{isNew ? ( {isNew ? (
<> <>
A new <em>story.</em> New <em>gallery.</em>
</> </>
) : ( ) : (
<> <>
@@ -306,9 +306,9 @@ export default function GalleryEditorPage() {
<div className="editor-maincol"> <div className="editor-maincol">
<section className="editor-section editor-section--first"> <section className="editor-section editor-section--first">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">01 / The introduction</p> <p className="studio-kicker">01 / Gallery_Metadata</p>
<h2>Give it a name.</h2> <h2>Gallery_Info</h2>
<p>This is the first thing your client will see.</p> <p>Title and client-facing description.</p>
</div> </div>
<div className="editor-fields editor-fields--two"> <div className="editor-fields editor-fields--two">
<label> <label>
@@ -331,11 +331,11 @@ export default function GalleryEditorPage() {
</label> </label>
</div> </div>
<label className="editor-fields__full"> <label className="editor-fields__full">
A note about this gallery Description:
<textarea <textarea
value={draft.description} value={draft.description}
onChange={(event) => setField('description', event.target.value)} onChange={(event) => setField('description', event.target.value)}
placeholder="A few words to set the scene..." placeholder="Optional gallery description"
rows={3} rows={3}
/> />
</label> </label>
@@ -343,8 +343,8 @@ export default function GalleryEditorPage() {
<section className="editor-section"> <section className="editor-section">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">02 / The work</p> <p className="studio-kicker">02 / Media_Buffer</p>
<h2>Bring it all in.</h2> <h2>Upload_Files</h2>
<p> <p>
Originals stay private in object storage. Previews are prepared in the background. Originals stay private in object storage. Previews are prepared in the background.
</p> </p>
@@ -382,9 +382,7 @@ export default function GalleryEditorPage() {
</div> </div>
)} )}
{gallery && gallery.media.length === 0 && ( {gallery && gallery.media.length === 0 && (
<div className="editor-media-empty"> <div className="editor-media-empty">No media uploaded.</div>
Your uploaded photographs will take center stage here.
</div>
)} )}
</section> </section>
</div> </div>
@@ -392,8 +390,8 @@ export default function GalleryEditorPage() {
<aside className="editor-aside"> <aside className="editor-aside">
<section className="editor-section editor-section--aside"> <section className="editor-section editor-section--aside">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">03 / Client controls</p> <p className="studio-kicker">03 / Client_Control</p>
<h2>Set the boundaries.</h2> <h2>Access_Settings</h2>
</div> </div>
<div className="toggle-list"> <div className="toggle-list">
<Toggle <Toggle
@@ -451,8 +449,8 @@ export default function GalleryEditorPage() {
<section className="editor-section editor-section--aside"> <section className="editor-section editor-section--aside">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">04 / The atmosphere</p> <p className="studio-kicker">04 / Gallery_Config</p>
<h2>Make it feel like you.</h2> <h2>Appearance_Module</h2>
</div> </div>
<div className="choice-label">Mode</div> <div className="choice-label">Mode</div>
<div className="choice-row"> <div className="choice-row">
@@ -512,15 +510,15 @@ export default function GalleryEditorPage() {
<section className="editor-section editor-section--aside"> <section className="editor-section editor-section--aside">
<div className="editor-section__heading"> <div className="editor-section__heading">
<p className="studio-kicker">05 / Your signature</p> <p className="studio-kicker">05 / Branding_Config</p>
<h2>Leave your mark.</h2> <h2>Studio_Identity</h2>
</div> </div>
<label className="editor-field-single"> <label className="editor-field-single">
Studio name{' '} Studio name{' '}
<input <input
value={draft.studioName} value={draft.studioName}
onChange={(event) => setField('studioName', event.target.value)} onChange={(event) => setField('studioName', event.target.value)}
placeholder={user?.name || 'Your studio'} placeholder={user?.name || 'Studio name'}
/> />
</label> </label>
<label className="editor-field-single"> <label className="editor-field-single">
@@ -528,7 +526,7 @@ export default function GalleryEditorPage() {
<input <input
value={draft.tagline} value={draft.tagline}
onChange={(event) => setField('tagline', event.target.value)} onChange={(event) => setField('tagline', event.target.value)}
placeholder="Photographs for keeps." placeholder="Studio tagline"
/> />
</label> </label>
<label className="editor-field-single"> <label className="editor-field-single">
@@ -0,0 +1,192 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link } from 'react-router-dom';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
import { useAuth } from '../../features/auth/useAuth';
import { changePassword, updateProfile } from '../../lib/api';
export default function SettingsPage() {
const { user, setUser, signOut } = useAuth();
const [name, setName] = useState(user?.name || '');
const [email, setEmail] = useState(user?.email || '');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [profileStatus, setProfileStatus] = useState('');
const [passwordStatus, setPasswordStatus] = useState('');
const [profileError, setProfileError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
useEffect(() => {
if (!user) return;
setName(user.name);
setEmail(user.email);
}, [user]);
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSavingProfile(true);
setProfileStatus('');
setProfileError('');
try {
const updated = await updateProfile(name, email);
setUser(updated);
setProfileStatus('Profile saved');
} catch (reason) {
setProfileError(reason instanceof Error ? reason.message : 'Profile update failed.');
} finally {
setSavingProfile(false);
}
}
async function savePassword(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPasswordStatus('');
setPasswordError('');
if (newPassword !== confirmPassword) {
setPasswordError('Passwords do not match');
return;
}
setSavingPassword(true);
try {
await changePassword(currentPassword, newPassword);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setPasswordStatus('Password updated');
} catch (reason) {
setPasswordError(reason instanceof Error ? reason.message : 'Password update failed.');
} finally {
setSavingPassword(false);
}
}
return (
<DashboardLayout>
<div className="studio-page settings-page">
<header className="studio-page__header settings-page__header">
<div>
<p className="studio-kicker">Workspace / Settings</p>
<h1 className="studio-display">Settings</h1>
<p className="studio-page__lede">Account profile, security, and session controls.</p>
</div>
</header>
<div className="settings-layout">
<section className="settings-panel">
<div className="settings-panel__heading">
<p className="studio-kicker">01 / Profile</p>
<h2>Studio Profile</h2>
<p>These details identify your photographer account.</p>
</div>
<form className="settings-form" onSubmit={saveProfile}>
<label>
Studio name:
<input
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
required
/>
</label>
<label>
Email:
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
type="email"
autoComplete="email"
required
/>
</label>
{profileError && (
<p className="settings-message settings-message--error">{profileError}</p>
)}
{profileStatus && (
<p className="settings-message settings-message--success">{profileStatus}</p>
)}
<button className="settings-button" type="submit" disabled={savingProfile}>
{savingProfile ? 'Saving...' : 'Save Profile'}
</button>
</form>
</section>
<section className="settings-panel">
<div className="settings-panel__heading">
<p className="studio-kicker">02 / Security</p>
<h2>Password Update</h2>
<p>Change the password used to access this console.</p>
</div>
<form className="settings-form" onSubmit={savePassword}>
<label>
Current password:
<input
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
type="password"
autoComplete="current-password"
required
/>
</label>
<label>
New password:
<input
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
type="password"
autoComplete="new-password"
minLength={8}
required
/>
</label>
<label>
Confirm new password:
<input
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
type="password"
autoComplete="new-password"
minLength={8}
required
/>
</label>
{passwordError && (
<p className="settings-message settings-message--error">{passwordError}</p>
)}
{passwordStatus && (
<p className="settings-message settings-message--success">{passwordStatus}</p>
)}
<button className="settings-button" type="submit" disabled={savingPassword}>
{savingPassword ? 'Updating...' : 'Update Password'}
</button>
</form>
</section>
<section className="settings-panel settings-panel--session">
<div className="settings-panel__heading">
<p className="studio-kicker">03 / Session</p>
<h2>Session Status</h2>
</div>
<div className="settings-session-row">
<span>Status:</span>
<strong>ACTIVE</strong>
</div>
<div className="settings-session-row">
<span>Account ID:</span>
<code>{user?.id}</code>
</div>
<div className="settings-session-actions">
<Link to="/dashboard/dev">Dev Console</Link>
<Link to="/dashboard/galleries">Galleries</Link>
<button type="button" onClick={() => void signOut()}>
Logout
</button>
</div>
</section>
</div>
</div>
</DashboardLayout>
);
}
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
export interface GiftItem {
id: string;
type: string;
title?: string;
text?: string;
mediaUrl?: string;
sortOrder: number;
metadata?: Record<string, unknown>;
}
export interface Gift {
id: string;
slug: string;
recipientName: string;
senderName: string;
title: string;
introMessage: string;
revealMessage: string;
items: GiftItem[];
}
-31
View File
@@ -1,31 +0,0 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS gifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
recipient_name TEXT NOT NULL,
sender_name TEXT NOT NULL,
title TEXT NOT NULL,
intro_message TEXT NOT NULL,
reveal_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT gifts_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE TABLE IF NOT EXISTS gift_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gift_id UUID NOT NULL REFERENCES gifts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT,
text TEXT,
media_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT gift_items_type_check CHECK (length(trim(type)) > 0)
);
CREATE INDEX IF NOT EXISTS gift_items_gift_id_sort_order_idx
ON gift_items (gift_id, sort_order, id);
-91
View File
@@ -1,91 +0,0 @@
INSERT INTO gifts (
id,
slug,
recipient_name,
sender_name,
title,
intro_message,
reveal_message,
status
)
VALUES (
'11111111-1111-4111-8111-111111111111',
'demo',
'Anna',
'Alex',
'A little surprise for you',
'I made something for you.',
'You make ordinary days feel like something worth remembering. Here is to all the little adventures still ahead.',
'published'
)
ON CONFLICT (slug) DO UPDATE SET
recipient_name = EXCLUDED.recipient_name,
sender_name = EXCLUDED.sender_name,
title = EXCLUDED.title,
intro_message = EXCLUDED.intro_message,
reveal_message = EXCLUDED.reveal_message,
status = EXCLUDED.status,
updated_at = NOW();
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'22222222-2222-4222-8222-222222222222',
id,
'image',
'The good kind of ordinary.',
'The tiny moments are usually the ones that stay with us the longest.',
'https://images.unsplash.com/photo-1511988617509-8e73b0f3b7d2?auto=format&fit=crop&w=1400&q=85',
1,
'{"caption":"A little snapshot of a very good day.","accent":"coral"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'33333333-3333-4333-8333-333333333333',
id,
'text',
'A note for your next chapter.',
'Keep choosing the places, people, and tiny rituals that make you feel most like yourself. I will always be cheering for you.',
NULL,
2,
'{"eyebrow":"A note from me","accent":"lavender"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'44444444-4444-4444-8444-444444444444',
id,
'text',
'One more thing.',
'There is nowhere else I would rather be than somewhere in the middle of the next story with you.',
NULL,
3,
'{"eyebrow":"P.S.","accent":"gold"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
-29
View File
@@ -1,29 +0,0 @@
CREATE TABLE IF NOT EXISTS gifts (
id TEXT PRIMARY KEY NOT NULL,
slug TEXT NOT NULL UNIQUE,
recipient_name TEXT NOT NULL,
sender_name TEXT NOT NULL,
title TEXT NOT NULL,
intro_message TEXT NOT NULL,
reveal_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT gifts_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE TABLE IF NOT EXISTS gift_items (
id TEXT PRIMARY KEY NOT NULL,
gift_id TEXT NOT NULL REFERENCES gifts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT,
text TEXT,
media_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT gift_items_type_check CHECK (length(trim(type)) > 0)
);
CREATE INDEX IF NOT EXISTS gift_items_gift_id_sort_order_idx
ON gift_items (gift_id, sort_order, id);
-91
View File
@@ -1,91 +0,0 @@
INSERT INTO gifts (
id,
slug,
recipient_name,
sender_name,
title,
intro_message,
reveal_message,
status
)
VALUES (
'11111111-1111-4111-8111-111111111111',
'demo',
'Anna',
'Alex',
'A little surprise for you',
'I made something for you.',
'You make ordinary days feel like something worth remembering. Here is to all the little adventures still ahead.',
'published'
)
ON CONFLICT (slug) DO UPDATE SET
recipient_name = excluded.recipient_name,
sender_name = excluded.sender_name,
title = excluded.title,
intro_message = excluded.intro_message,
reveal_message = excluded.reveal_message,
status = excluded.status,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'22222222-2222-4222-8222-222222222222',
id,
'image',
'The good kind of ordinary.',
'The tiny moments are usually the ones that stay with us the longest.',
'https://images.unsplash.com/photo-1511988617509-8e73b0f3b7d2?auto=format&fit=crop&w=1400&q=85',
1,
'{"caption":"A little snapshot of a very good day.","accent":"coral"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'33333333-3333-4333-8333-333333333333',
id,
'text',
'A note for your next chapter.',
'Keep choosing the places, people, and tiny rituals that make you feel most like yourself. I will always be cheering for you.',
NULL,
2,
'{"eyebrow":"A note from me","accent":"lavender"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'44444444-4444-4444-8444-444444444444',
id,
'text',
'One more thing.',
'There is nowhere else I would rather be than somewhere in the middle of the next story with you.',
NULL,
3,
'{"eyebrow":"P.S.","accent":"gold"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;