ai slop ah
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev frontend-dev migrate seed test build lint format compose-up compose-down
|
||||
.PHONY: dev frontend-dev migrate seed swagger test build lint format compose-up compose-down
|
||||
|
||||
dev:
|
||||
go -C backend run ./cmd/server
|
||||
@@ -12,6 +12,9 @@ migrate:
|
||||
seed:
|
||||
go -C backend run ./cmd/seed
|
||||
|
||||
swagger:
|
||||
go -C backend run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g cmd/server/main.go -o docs --parseInternal
|
||||
|
||||
test:
|
||||
go -C backend test ./...
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Northline Delivery Studio
|
||||
# Noah Bianchi Studio
|
||||
|
||||
Northline is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop:
|
||||
Noah Bianchi is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop:
|
||||
|
||||
```text
|
||||
Upload -> Curate -> Customize -> Publish -> Send a private link
|
||||
@@ -11,7 +11,7 @@ Photographers get a focused studio dashboard. Clients get a private editorial ga
|
||||
## Stack
|
||||
|
||||
- Frontend: React, TypeScript, Vite, React Router, Tailwind CSS, Framer Motion
|
||||
- Backend: Go 1.24+, `net/http`, REST/JSON, `database/sql`
|
||||
- Backend: Go 1.24+, Gin, REST/JSON, `database/sql`
|
||||
- Database: PostgreSQL by default; SQLite remains available for lightweight local testing
|
||||
- Storage: MinIO locally through an S3-compatible storage interface
|
||||
- Media: direct browser uploads with presigned URLs, asynchronous preview processing, signed download URLs
|
||||
@@ -52,6 +52,7 @@ Open:
|
||||
- Dashboard: [http://localhost:5173/login](http://localhost:5173/login)
|
||||
- Dev diagnostics: [http://localhost:5173/dashboard/dev](http://localhost:5173/dashboard/dev)
|
||||
- Demo client gallery: [http://localhost:5173/g/emma-james-wedding](http://localhost:5173/g/emma-james-wedding)
|
||||
- API documentation: [http://localhost:8080/swagger/index.html](http://localhost:8080/swagger/index.html)
|
||||
- MinIO console: [http://localhost:9001](http://localhost:9001)
|
||||
|
||||
### PowerShell
|
||||
@@ -78,7 +79,7 @@ The seed command creates:
|
||||
```text
|
||||
Email: demo@example.com
|
||||
Password: DemoPassword123!
|
||||
Studio: Northline Studio
|
||||
Studio: Noah Bianchi
|
||||
```
|
||||
|
||||
It also creates the published demo gallery:
|
||||
@@ -187,6 +188,8 @@ POST /api/auth/register
|
||||
POST /api/auth/login
|
||||
POST /api/auth/logout
|
||||
GET /api/auth/me
|
||||
PATCH /api/auth/me
|
||||
POST /api/auth/password
|
||||
```
|
||||
|
||||
Authenticated galleries:
|
||||
@@ -232,6 +235,12 @@ Health:
|
||||
GET /health
|
||||
```
|
||||
|
||||
Swagger UI is available at `/swagger/index.html`; the generated Swagger document is also served at `/swagger/doc.json`. Regenerate the checked-in documentation after changing API annotations with:
|
||||
|
||||
```bash
|
||||
make swagger
|
||||
```
|
||||
|
||||
Photographer routes use an HTTP-only signed session cookie. Public visitor identity and gallery access are separate signed cookies; clients do not need accounts.
|
||||
|
||||
## Database
|
||||
@@ -256,7 +265,7 @@ The schema does not store uploaded media content. `external_url` exists only to
|
||||
|
||||
```text
|
||||
backend/
|
||||
cmd/server/ API and worker startup
|
||||
cmd/server/ Gin API router and worker startup
|
||||
cmd/migrate/ dialect-aware SQL migration runner
|
||||
cmd/seed/ demo photographer and gallery seed
|
||||
internal/auth/ bcrypt auth, signed sessions, visitor access cookies
|
||||
@@ -281,6 +290,8 @@ Preview and public delivery use the same `ClientGallery` component. The preview
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make lint
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
@@ -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"]
|
||||
@@ -29,7 +29,7 @@ func main() {
|
||||
if err := seed(ctx, database); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail)
|
||||
log.Printf("seeded %s with the Noah Bianchi demo gallery", demoEmail)
|
||||
}
|
||||
|
||||
func seed(ctx context.Context, database *sql.DB) error {
|
||||
@@ -41,7 +41,7 @@ func seed(ctx context.Context, database *sql.DB) error {
|
||||
INSERT INTO users (id, email, password_hash, name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
|
||||
`, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil {
|
||||
`, demoUserID, demoEmail, string(passwordHash), "Noah Bianchi"); err != nil {
|
||||
return fmt.Errorf("seed demo user: %w", err)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func seed(ctx context.Context, database *sql.DB) error {
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID,
|
||||
`{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`,
|
||||
`{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil {
|
||||
`{"studioName":"Noah Bianchi","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil {
|
||||
return fmt.Errorf("seed demo gallery: %w", err)
|
||||
}
|
||||
|
||||
|
||||
+55
-36
@@ -10,17 +10,29 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/example/sndit/backend/docs"
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/config"
|
||||
"github.com/example/sndit/backend/internal/db"
|
||||
devtools "github.com/example/sndit/backend/internal/dev"
|
||||
"github.com/example/sndit/backend/internal/downloads"
|
||||
"github.com/example/sndit/backend/internal/galleries"
|
||||
"github.com/example/sndit/backend/internal/gifts"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// @title Noah Bianchi Studio API
|
||||
// @version 1.0
|
||||
// @description REST API for photographer galleries and client delivery.
|
||||
// @schemes http https
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey studioSession
|
||||
// @in header
|
||||
// @name Cookie
|
||||
// @description Use the studio_session cookie as studio_session=<value>.
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -73,23 +85,25 @@ func main() {
|
||||
CookieSecure: cfg.CookieSecure,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", health)
|
||||
authHandler.RegisterRoutes(mux)
|
||||
galleryHandler.RegisterProtectedRoutes(mux, authService.Require)
|
||||
mediaHandler.RegisterRoutes(mux, authService.Require)
|
||||
galleryHandler.RegisterPublicRoutes(mux)
|
||||
downloadHandler.RegisterRoutes(mux)
|
||||
devHandler.RegisterRoutes(mux, authService.Require)
|
||||
|
||||
// Keep the original public gift endpoint available while the gallery product
|
||||
// uses /api/public/galleries/:slug.
|
||||
legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database)))
|
||||
mux.Handle("/api/gifts/", legacyGifts.Routes())
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery(), withCORS(cfg.CORSOrigin), withLogging())
|
||||
router.GET("/health", health)
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler,
|
||||
ginSwagger.DocExpansion("none"),
|
||||
ginSwagger.PersistAuthorization(true),
|
||||
))
|
||||
require := authService.Require()
|
||||
authHandler.RegisterRoutes(router)
|
||||
authHandler.RegisterProtectedRoutes(router, require)
|
||||
galleryHandler.RegisterProtectedRoutes(router, require)
|
||||
mediaHandler.RegisterRoutes(router, require)
|
||||
galleryHandler.RegisterPublicRoutes(router)
|
||||
downloadHandler.RegisterRoutes(router)
|
||||
devHandler.RegisterRoutes(router, require)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: withCORS(withLogging(mux), cfg.CORSOrigin),
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
@@ -111,28 +125,33 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func health(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}` + "\n"))
|
||||
// health godoc
|
||||
// @Summary Check API health
|
||||
// @Tags system
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
func health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
func withCORS(allowedOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" && originAllowed(origin, allowedOrigin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Status(http.StatusNoContent)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func originAllowed(origin, configured string) bool {
|
||||
@@ -144,10 +163,10 @@ func originAllowed(origin, configured string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
func withLogging() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
started := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond))
|
||||
})
|
||||
c.Next()
|
||||
log.Printf("%s %s %d %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started).Round(time.Millisecond))
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -3,36 +3,74 @@ module github.com/example/sndit/backend
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/minio/minio-go/v7 v7.0.95
|
||||
golang.org/x/crypto v0.39.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.41.0
|
||||
modernc.org/sqlite v1.39.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.0.2 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.41.0 // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
+145
-10
@@ -1,12 +1,56 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -21,11 +65,28 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
|
||||
@@ -34,42 +95,116 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
|
||||
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -15,11 +17,16 @@ func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/auth/register", h.Register)
|
||||
mux.HandleFunc("POST /api/auth/login", h.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", h.Logout)
|
||||
mux.HandleFunc("GET /api/auth/me", h.Me)
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter) {
|
||||
router.POST("/api/auth/register", h.Register)
|
||||
router.POST("/api/auth/login", h.Login)
|
||||
router.POST("/api/auth/logout", h.Logout)
|
||||
router.GET("/api/auth/me", h.Me)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.PATCH("/api/auth/me", require, h.UpdateMe)
|
||||
router.POST("/api/auth/password", require, h.ChangePassword)
|
||||
}
|
||||
|
||||
type credentialsRequest struct {
|
||||
@@ -28,66 +35,188 @@ type credentialsRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
type profileRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a photographer account
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body credentialsRequest true "Account details"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/auth/register [post]
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var request credentialsRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name)
|
||||
user, err := h.service.Register(c.Request.Context(), request.Email, request.Password, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.service.SetSession(w, user)
|
||||
writeJSON(w, http.StatusCreated, map[string]User{"user": user})
|
||||
h.service.SetSession(c, user)
|
||||
writeJSON(c, http.StatusCreated, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
// Login godoc
|
||||
// @Summary Sign in a photographer
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body credentialsRequest true "Account credentials"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/login [post]
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var request credentialsRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
user, err := h.service.Login(r.Context(), request.Email, request.Password)
|
||||
user, err := h.service.Login(c.Request.Context(), request.Email, request.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
|
||||
return
|
||||
}
|
||||
h.service.SetSession(w, user)
|
||||
writeJSON(w, http.StatusOK, map[string]User{"user": user})
|
||||
h.service.SetSession(c, user)
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
|
||||
h.service.ClearSession(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// Logout godoc
|
||||
// @Summary Sign out the current photographer
|
||||
// @Tags authentication
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/auth/logout [post]
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
h.service.ClearSession(c)
|
||||
writeJSON(c, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.service.UserFromRequest(r.Context(), r)
|
||||
// Me godoc
|
||||
// @Summary Get the current photographer
|
||||
// @Tags authentication
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/me [get]
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
user, err := h.service.UserFromRequest(c.Request.Context(), c.Request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]User{"user": user})
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
|
||||
// UpdateMe godoc
|
||||
// @Summary Update the current photographer profile
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body profileRequest true "Profile details"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/auth/me [patch]
|
||||
func (h *Handler) UpdateMe(c *gin.Context) {
|
||||
user, ok := UserFromContext(c)
|
||||
if !ok {
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
var request profileRequest
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateProfile(c.Request.Context(), user.ID, request.Email, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "enter ") || strings.HasPrefix(err.Error(), "name ") {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update profile"})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": updated})
|
||||
}
|
||||
|
||||
// ChangePassword godoc
|
||||
// @Summary Change the current photographer password
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body passwordRequest true "Password details"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/password [post]
|
||||
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||
user, ok := UserFromContext(c)
|
||||
if !ok {
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
var request passwordRequest
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if err := h.service.ChangePassword(c.Request.Context(), user.ID, request.CurrentPassword, request.NewPassword); err != nil {
|
||||
if errors.Is(err, ErrCurrentPassword) {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": "current password is incorrect"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "new password") {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update password"})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeJSON(c, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -66,3 +66,50 @@ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (storedUser, error) {
|
||||
var user storedUser
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, name, password_hash
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedUser{}, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return storedUser{}, fmt.Errorf("find user credentials: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUser(ctx context.Context, id uuid.UUID, email, name string) (User, error) {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET email = $1, name = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
`, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), id)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return User{}, ErrEmailTaken
|
||||
}
|
||||
return User{}, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
`, passwordHash, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil || count == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -29,6 +30,7 @@ const (
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidSession = errors.New("invalid session")
|
||||
ErrCurrentPassword = errors.New("current password is incorrect")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -78,33 +80,47 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro
|
||||
return user.User, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSession(w http.ResponseWriter, user User) {
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, name string) (User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if !strings.Contains(email, "@") || len(email) > 254 {
|
||||
return User{}, fmt.Errorf("enter a valid email address")
|
||||
}
|
||||
if name == "" || len(name) > 120 {
|
||||
return User{}, fmt.Errorf("name is required")
|
||||
}
|
||||
return s.repository.UpdateUser(ctx, userID, email, name)
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
if len(newPassword) < 8 {
|
||||
return fmt.Errorf("new password must be at least 8 characters")
|
||||
}
|
||||
user, err := s.repository.FindByIDWithPassword(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash new password: %w", err)
|
||||
}
|
||||
return s.repository.UpdatePassword(ctx, userID, string(hash))
|
||||
}
|
||||
|
||||
func (s *Service) SetSession(c *gin.Context, user User) {
|
||||
payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()}
|
||||
token, err := s.signJSON(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, sessionCookieName, token, int(sessionDuration.Seconds()), true)
|
||||
}
|
||||
|
||||
func (s *Service) ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
func (s *Service) ClearSession(c *gin.Context) {
|
||||
s.setCookie(c, sessionCookieName, "", -1, true)
|
||||
}
|
||||
|
||||
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) {
|
||||
@@ -131,61 +147,47 @@ type contextKey string
|
||||
|
||||
const userContextKey contextKey = "authenticated-user"
|
||||
|
||||
func (s *Service) Require(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.UserFromRequest(r.Context(), r)
|
||||
func (s *Service) Require() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := s.UserFromRequest(c.Request.Context(), c.Request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
})
|
||||
c.Set(userContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (User, bool) {
|
||||
user, ok := ctx.Value(userContextKey).(User)
|
||||
return user, ok
|
||||
func UserFromContext(c *gin.Context) (User, bool) {
|
||||
value, ok := c.Get(userContextKey)
|
||||
user, valid := value.(User)
|
||||
return user, ok && valid
|
||||
}
|
||||
|
||||
func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(visitorCookieName); err == nil {
|
||||
func (s *Service) EnsureVisitor(c *gin.Context) string {
|
||||
if cookie, err := c.Request.Cookie(visitorCookieName); err == nil {
|
||||
if _, err := uuid.Parse(cookie.Value); err == nil {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
visitorID := uuid.NewString()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: visitorCookieName,
|
||||
Value: visitorID,
|
||||
Path: "/",
|
||||
MaxAge: int(365 * 24 * time.Hour / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, visitorCookieName, visitorID, int(365*24*time.Hour/time.Second), true)
|
||||
return visitorID
|
||||
}
|
||||
|
||||
func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) {
|
||||
func (s *Service) GrantGalleryAccess(c *gin.Context, slug string) {
|
||||
payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()}
|
||||
token, err := s.signJSON(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: accessCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(accessDuration.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, accessCookieName, token, int(accessDuration.Seconds()), true)
|
||||
}
|
||||
|
||||
func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
|
||||
cookie, err := r.Cookie(accessCookieName)
|
||||
func (s *Service) HasGalleryAccess(c *gin.Context, slug string) bool {
|
||||
cookie, err := c.Request.Cookie(accessCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -196,6 +198,11 @@ func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
|
||||
return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix()
|
||||
}
|
||||
|
||||
func (s *Service) setCookie(c *gin.Context, name, value string, maxAge int, httpOnly bool) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(name, value, maxAge, "/", "", s.secure, httpOnly)
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
@@ -235,9 +242,3 @@ func (s *Service) signature(value string) string {
|
||||
_, _ = hash.Write([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRegisterLoginAndSession(t *testing.T) {
|
||||
@@ -33,7 +35,7 @@ func TestRegisterLoginAndSession(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio")
|
||||
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Noah Bianchi")
|
||||
if err != nil {
|
||||
t.Fatalf("register user: %v", err)
|
||||
}
|
||||
@@ -44,9 +46,24 @@ func TestRegisterLoginAndSession(t *testing.T) {
|
||||
if err != nil || loggedIn.ID != user.ID {
|
||||
t.Fatalf("login failed: user=%+v err=%v", loggedIn, err)
|
||||
}
|
||||
updated, err := service.UpdateProfile(ctx, user.ID, "updated@example.com", "Updated Studio")
|
||||
if err != nil || updated.Email != "updated@example.com" || updated.Name != "Updated Studio" {
|
||||
t.Fatalf("profile update failed: user=%+v err=%v", updated, err)
|
||||
}
|
||||
if err := service.ChangePassword(ctx, user.ID, "DemoPassword123!", "NewPassword123!"); err != nil {
|
||||
t.Fatalf("password update failed: %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "updated@example.com", "DemoPassword123!"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("old password should be invalid, got %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "updated@example.com", "NewPassword123!"); err != nil {
|
||||
t.Fatalf("new password should work: %v", err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
service.SetSession(recorder, user)
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
service.SetSession(ginContext, user)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
request.AddCookie(recorder.Result().Cookies()[0])
|
||||
fromSession, err := service.UserFromRequest(ctx, request)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -35,28 +35,36 @@ func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Ser
|
||||
return &Handler{db: db, storage: objectStorage, auth: authService, config: config}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics)))
|
||||
mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck)))
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/dev/diagnostics", require, h.Diagnostics)
|
||||
router.POST("/api/dev/storage-check", require, h.StorageCheck)
|
||||
}
|
||||
|
||||
func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
// Diagnostics godoc
|
||||
// @Summary Inspect local development dependencies
|
||||
// @Tags development
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/dev/diagnostics [get]
|
||||
func (h *Handler) Diagnostics(c *gin.Context) {
|
||||
user, _ := auth.UserFromContext(c)
|
||||
databaseError := ""
|
||||
databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
databaseContext, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
if err := h.db.PingContext(databaseContext); err != nil {
|
||||
databaseError = err.Error()
|
||||
}
|
||||
cancel()
|
||||
|
||||
storageError := ""
|
||||
storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
storageContext, storageCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
if err := h.storage.EnsureBucket(storageContext); err != nil {
|
||||
storageError = err.Error()
|
||||
}
|
||||
storageCancel()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
writeJSON(c, http.StatusOK, map[string]any{
|
||||
"environment": "development",
|
||||
"now": time.Now().UTC().Format(time.RFC3339),
|
||||
"user": map[string]string{
|
||||
@@ -84,33 +92,43 @@ func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.storage.EnsureBucket(r.Context()); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
|
||||
// StorageCheck godoc
|
||||
// @Summary Check MinIO storage read/write access
|
||||
// @Tags development
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]interface{}
|
||||
// @Router /api/dev/storage-check [post]
|
||||
func (h *Handler) StorageCheck(c *gin.Context) {
|
||||
if err := h.storage.EnsureBucket(c.Request.Context()); err != nil {
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var randomBytes [12]byte
|
||||
if _, err := rand.Read(randomBytes[:]); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:]))
|
||||
contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
|
||||
contents := "studio storage check " + time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := h.storage.Put(c.Request.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
info, statErr := h.storage.Stat(r.Context(), key)
|
||||
deleteErr := h.storage.Delete(r.Context(), key)
|
||||
info, statErr := h.storage.Stat(c.Request.Context(), key)
|
||||
deleteErr := h.storage.Delete(c.Request.Context(), key)
|
||||
if statErr != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
|
||||
return
|
||||
}
|
||||
if deleteErr != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
|
||||
}
|
||||
|
||||
func splitOrigins(value string) []string {
|
||||
@@ -123,8 +141,6 @@ func splitOrigins(value string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package downloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/galleries"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -25,80 +25,114 @@ func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.
|
||||
return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll)
|
||||
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus)
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter) {
|
||||
router.POST("/api/public/galleries/:slug/media/:mediaId/download", h.Download)
|
||||
router.POST("/api/public/galleries/:slug/download-all", h.DownloadAll)
|
||||
router.GET("/api/public/galleries/:slug/download-all/:jobId", h.DownloadAllStatus)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// Download godoc
|
||||
// @Summary Download one public gallery media item
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/download [post]
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "downloads are disabled")
|
||||
return
|
||||
}
|
||||
mediaID, err := uuid.Parse(r.PathValue("mediaId"))
|
||||
mediaID, err := uuid.Parse(c.Param("mediaId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.media.GetByID(r.Context(), mediaID)
|
||||
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
||||
if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
url, err := h.downloadURL(r, item)
|
||||
url, err := h.downloadURL(c, item)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": url})
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
_ = h.media.RecordDownload(c.Request.Context(), record.ID, &mediaID, visitorID)
|
||||
writeJSON(c, http.StatusOK, map[string]string{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// DownloadAll godoc
|
||||
// @Summary Start a public gallery ZIP download
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Success 202 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/download-all [post]
|
||||
func (h *Handler) DownloadAll(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "gallery downloads are disabled")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
job, err := h.service.Create(r.Context(), record.ID, visitorID)
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
job, err := h.service.Create(c.Request.Context(), record.ID, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not start gallery download")
|
||||
writeError(c, http.StatusInternalServerError, "could not start gallery download")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
|
||||
writeJSON(c, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// DownloadAllStatus godoc
|
||||
// @Summary Get public gallery ZIP download status
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param jobId path string true "Download job UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/download-all/{jobId} [get]
|
||||
func (h *Handler) DownloadAllStatus(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "gallery downloads are disabled")
|
||||
return
|
||||
}
|
||||
jobID, err := uuid.Parse(r.PathValue("jobId"))
|
||||
jobID, err := uuid.Parse(c.Param("jobId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid download job id")
|
||||
writeError(c, http.StatusBadRequest, "invalid download job id")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID)
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
job, err := h.service.Get(c.Request.Context(), jobID, record.ID, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "download job not found")
|
||||
writeError(c, http.StatusNotFound, "download job not found")
|
||||
return
|
||||
}
|
||||
response := map[string]any{"jobId": job.ID.String(), "status": job.Status}
|
||||
@@ -106,40 +140,38 @@ func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
|
||||
response["error"] = job.Error
|
||||
}
|
||||
if job.Status == StatusReady {
|
||||
url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour)
|
||||
url, err := h.storage.CreateDownloadURL(c.Request.Context(), job.StorageKey, time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download URL")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download URL")
|
||||
return
|
||||
}
|
||||
response["url"] = url
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
writeJSON(c, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) {
|
||||
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
|
||||
func (h *Handler) publicRecord(c *gin.Context) (galleries.GalleryRecord, error) {
|
||||
record, err := h.galleries.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
|
||||
if err != nil || record.IsExpired() {
|
||||
return galleries.GalleryRecord{}, galleries.ErrNotFound
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
return galleries.GalleryRecord{}, galleries.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) {
|
||||
func (h *Handler) downloadURL(c *gin.Context, item media.Record) (string, error) {
|
||||
if item.ExternalURL != "" {
|
||||
return item.ExternalURL, nil
|
||||
}
|
||||
return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
|
||||
return h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,22 +35,22 @@ func NewHandler(repository *Repository, mediaRepository *media.Repository, objec
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List)))
|
||||
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create)))
|
||||
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get)))
|
||||
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update)))
|
||||
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish)))
|
||||
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish)))
|
||||
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview)))
|
||||
func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/galleries", require, h.List)
|
||||
router.POST("/api/galleries", require, h.Create)
|
||||
router.GET("/api/galleries/:id", require, h.Get)
|
||||
router.PATCH("/api/galleries/:id", require, h.Update)
|
||||
router.DELETE("/api/galleries/:id", require, h.Delete)
|
||||
router.POST("/api/galleries/:id/publish", require, h.Publish)
|
||||
router.POST("/api/galleries/:id/unpublish", require, h.Unpublish)
|
||||
router.GET("/api/galleries/:id/preview", require, h.Preview)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite)
|
||||
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite)
|
||||
func (h *Handler) RegisterPublicRoutes(router gin.IRouter) {
|
||||
router.GET("/api/public/galleries/:slug", h.Public)
|
||||
router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic)
|
||||
router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite)
|
||||
router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite)
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
@@ -78,15 +79,23 @@ type publicPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// List godoc
|
||||
// @Summary List owned galleries
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
|
||||
summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load galleries")
|
||||
writeError(c, http.StatusInternalServerError, "could not load galleries")
|
||||
return
|
||||
}
|
||||
for index := range summaries {
|
||||
@@ -97,97 +106,141 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
|
||||
summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Create godoc
|
||||
// @Summary Create a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body createRequest true "Gallery details"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
var request createRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
request.Title = strings.TrimSpace(request.Title)
|
||||
request.ClientName = strings.TrimSpace(request.ClientName)
|
||||
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
record, err := h.repository.Create(c.Request.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not create gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Get godoc
|
||||
// @Summary Get an owned gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [get]
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Preview godoc
|
||||
// @Summary Preview a gallery as a client
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/preview [get]
|
||||
func (h *Handler) Preview(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
|
||||
writeError(c, http.StatusInternalServerError, "could not build gallery preview")
|
||||
return
|
||||
}
|
||||
gallery.Preview = true
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Update godoc
|
||||
// @Summary Update a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Param request body map[string]interface{} true "Gallery changes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [patch]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
current, err := h.recordForUser(r, user.ID)
|
||||
current, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
var request updateRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,19 +270,19 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
input.Description = strings.TrimSpace(*request.Description)
|
||||
}
|
||||
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
if request.Password != nil {
|
||||
if strings.TrimSpace(*request.Password) == "" {
|
||||
input.ClearPassword = true
|
||||
} else if len(*request.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
writeError(c, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
return
|
||||
} else {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
|
||||
writeError(c, http.StatusInternalServerError, "could not secure gallery password")
|
||||
return
|
||||
}
|
||||
hashed := string(hash)
|
||||
@@ -257,7 +310,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
value := strings.TrimSpace(*request.ExpiresAt)
|
||||
if value != "" {
|
||||
if _, err := time.Parse(time.RFC3339, value); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
writeError(c, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -268,12 +321,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if value != "" {
|
||||
coverID, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid cover media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid cover media id")
|
||||
return
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil || cover.GalleryID != current.ID {
|
||||
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
writeError(c, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -281,182 +334,255 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(request.ThemeConfig) > 0 {
|
||||
if !json.Valid(request.ThemeConfig) {
|
||||
writeError(w, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.ThemeConfig = request.ThemeConfig
|
||||
}
|
||||
if len(request.BrandingConfig) > 0 {
|
||||
if !json.Valid(request.BrandingConfig) {
|
||||
writeError(w, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.BrandingConfig = request.BrandingConfig
|
||||
}
|
||||
|
||||
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
|
||||
record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery")
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusPublished)
|
||||
// Publish godoc
|
||||
// @Summary Publish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/publish [post]
|
||||
func (h *Handler) Publish(c *gin.Context) {
|
||||
h.setStatus(c, StatusPublished)
|
||||
}
|
||||
|
||||
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusDraft)
|
||||
// Unpublish godoc
|
||||
// @Summary Unpublish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/unpublish [post]
|
||||
func (h *Handler) Unpublish(c *gin.Context) {
|
||||
h.setStatus(c, StatusDraft)
|
||||
}
|
||||
|
||||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
func (h *Handler) setStatus(c *gin.Context, status string) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
|
||||
record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery status")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery status")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Delete godoc
|
||||
// @Summary Delete a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 204
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
id, err := pathUUID(r, "id")
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(w, err)
|
||||
if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// Public godoc
|
||||
// @Summary Get a published public gallery
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug} [get]
|
||||
func (h *Handler) Public(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
gallery := h.lockedPayload(record)
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
return
|
||||
}
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// AuthenticatePublic godoc
|
||||
// @Summary Authenticate to a password-protected gallery
|
||||
// @Tags public galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param request body publicPasswordRequest true "Gallery password"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/authenticate [post]
|
||||
func (h *Handler) AuthenticatePublic(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
var request publicPasswordRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
|
||||
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
|
||||
writeError(c, http.StatusUnauthorized, "incorrect gallery password")
|
||||
return
|
||||
}
|
||||
h.auth.GrantGalleryAccess(w, record.Slug)
|
||||
h.auth.GrantGalleryAccess(c, record.Slug)
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, true)
|
||||
// Favorite godoc
|
||||
// @Summary Favorite a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [post]
|
||||
func (h *Handler) Favorite(c *gin.Context) {
|
||||
h.setFavorite(c, true)
|
||||
}
|
||||
|
||||
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, false)
|
||||
// Unfavorite godoc
|
||||
// @Summary Remove a favorite from a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [delete]
|
||||
func (h *Handler) Unfavorite(c *gin.Context) {
|
||||
h.setFavorite(c, false)
|
||||
}
|
||||
|
||||
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(r)
|
||||
func (h *Handler) setFavorite(c *gin.Context, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.FavoritesEnabled {
|
||||
writeError(w, http.StatusForbidden, "favorites are disabled")
|
||||
writeError(c, http.StatusForbidden, "favorites are disabled")
|
||||
return
|
||||
}
|
||||
mediaID, err := pathUUID(r, "mediaId")
|
||||
mediaID, err := pathUUID(c, "mediaId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.media.GetByID(r.Context(), mediaID)
|
||||
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
||||
if err != nil || item.GalleryID != record.ID {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update favorite")
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(c, http.StatusInternalServerError, "could not update favorite")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
writeJSON(c, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
}
|
||||
|
||||
func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
|
||||
func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
|
||||
if err != nil || record.IsExpired() {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
func (h *Handler) publicPayload(ctx context.Context, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
items, err := h.media.ListByGallery(ctx, record.ID)
|
||||
if err != nil {
|
||||
return Public{}, err
|
||||
@@ -509,12 +635,12 @@ func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(r, "id")
|
||||
func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
return GalleryRecord{}, err
|
||||
}
|
||||
return h.repository.GetForUser(r.Context(), userID, id)
|
||||
return h.repository.GetForUser(c.Request.Context(), userID, id)
|
||||
}
|
||||
|
||||
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
|
||||
@@ -628,8 +754,8 @@ func newSlug(title string) string {
|
||||
return slug + "-" + suffix
|
||||
}
|
||||
|
||||
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(r.PathValue(name))
|
||||
func pathUUID(c *gin.Context, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid %s", name)
|
||||
}
|
||||
@@ -644,34 +770,32 @@ func stringPointer(value string) *string {
|
||||
return ©
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeGalleryError(w http.ResponseWriter, err error) {
|
||||
func writeGalleryError(c *gin.Context, err error) {
|
||||
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -40,13 +41,13 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
|
||||
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8)
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil {
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Noah Bianchi"}`); err != nil {
|
||||
t.Fatalf("insert gallery: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
@@ -61,12 +62,12 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterPublicRoutes(mux)
|
||||
router := gin.New()
|
||||
handler.RegisterPublicRoutes(router)
|
||||
|
||||
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(getRecorder, getRequest)
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
favoriteRequest.AddCookie(cookie)
|
||||
}
|
||||
favoriteRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
router.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
if favoriteRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
}
|
||||
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
repository := NewRepository(database)
|
||||
@@ -62,7 +62,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
FavoritesEnabled: true,
|
||||
DownloadAllEnabled: true,
|
||||
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Noah Bianchi"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update gallery: %v", err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -35,14 +36,14 @@ func NewHandler(repository *Repository, objectStorage storage.Storage, processor
|
||||
return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List)))
|
||||
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload)))
|
||||
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload)))
|
||||
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update)))
|
||||
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download)))
|
||||
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/galleries/:id/media", require, h.List)
|
||||
router.POST("/api/galleries/:id/uploads", require, h.CreateUpload)
|
||||
router.POST("/api/uploads/:id/complete", require, h.CompleteUpload)
|
||||
router.PATCH("/api/media/:id", require, h.Update)
|
||||
router.POST("/api/media/:id/download", require, h.Download)
|
||||
router.DELETE("/api/uploads/:id", require, h.Delete)
|
||||
router.DELETE("/api/media/:id", require, h.Delete)
|
||||
}
|
||||
|
||||
type uploadRequest struct {
|
||||
@@ -55,57 +56,82 @@ type updateRequest struct {
|
||||
SortOrder *int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// List godoc
|
||||
// @Summary List gallery media
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/media [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
galleryID, err := parseID(r.PathValue("id"))
|
||||
galleryID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid gallery id")
|
||||
writeError(c, http.StatusBadRequest, "invalid gallery id")
|
||||
return
|
||||
}
|
||||
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
|
||||
belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID)
|
||||
if err != nil || !belongs {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
items, err := h.repository.ListByGallery(r.Context(), galleryID)
|
||||
items, err := h.repository.ListByGallery(c.Request.Context(), galleryID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load media")
|
||||
return
|
||||
}
|
||||
views := make([]Public, 0, len(items))
|
||||
for _, item := range items {
|
||||
view, err := h.view(r.Context(), item, true)
|
||||
view, err := h.view(c.Request.Context(), item, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": views})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": views})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// CreateUpload godoc
|
||||
// @Summary Create a presigned media upload
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Param request body uploadRequest true "Upload metadata"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/uploads [post]
|
||||
func (h *Handler) CreateUpload(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
galleryID, err := parseID(r.PathValue("id"))
|
||||
galleryID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid gallery id")
|
||||
writeError(c, http.StatusBadRequest, "invalid gallery id")
|
||||
return
|
||||
}
|
||||
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
|
||||
belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID)
|
||||
if err != nil || !belongs {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
var request uploadRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
filename := safeFilename(request.Filename)
|
||||
@@ -114,145 +140,192 @@ func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(filename))
|
||||
}
|
||||
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
|
||||
writeError(w, http.StatusBadRequest, "unsupported media file")
|
||||
writeError(c, http.StatusBadRequest, "unsupported media file")
|
||||
return
|
||||
}
|
||||
if request.FileSize <= 0 || request.FileSize > maxUploadSize {
|
||||
writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
|
||||
writeError(c, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
|
||||
return
|
||||
}
|
||||
mediaID := uuid.New()
|
||||
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename)
|
||||
item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
|
||||
item, err := h.repository.Create(c.Request.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create upload")
|
||||
writeError(c, http.StatusInternalServerError, "could not create upload")
|
||||
return
|
||||
}
|
||||
uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration)
|
||||
uploadURL, err := h.storage.CreateUploadURL(c.Request.Context(), storageKey, mimeType, uploadURLDuration)
|
||||
if err != nil {
|
||||
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID)
|
||||
writeError(w, http.StatusInternalServerError, "could not create upload URL")
|
||||
_, _ = h.repository.Delete(c.Request.Context(), user.ID, mediaID)
|
||||
writeError(c, http.StatusInternalServerError, "could not create upload URL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
writeJSON(c, http.StatusCreated, map[string]any{
|
||||
"uploadId": mediaID.String(),
|
||||
"uploadUrl": uploadURL,
|
||||
"media": publicFromRecord(item),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// CompleteUpload godoc
|
||||
// @Summary Complete a media upload
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/uploads/{id}/complete [post]
|
||||
func (h *Handler) CompleteUpload(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
info, err := h.storage.Stat(r.Context(), item.StorageKey)
|
||||
info, err := h.storage.Stat(c.Request.Context(), item.StorageKey)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "uploaded object is not available yet")
|
||||
writeError(c, http.StatusBadRequest, "uploaded object is not available yet")
|
||||
return
|
||||
}
|
||||
item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size)
|
||||
item, err = h.repository.Complete(c.Request.Context(), user.ID, mediaID, info.Size)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not complete upload")
|
||||
writeError(c, http.StatusInternalServerError, "could not complete upload")
|
||||
return
|
||||
}
|
||||
h.processor.Enqueue(item.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Update godoc
|
||||
// @Summary Update media ordering
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Param request body updateRequest true "Media changes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/media/{id} [patch]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
var request updateRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if request.SortOrder == nil || *request.SortOrder < 0 {
|
||||
writeError(w, http.StatusBadRequest, "sort order must be zero or greater")
|
||||
writeError(c, http.StatusBadRequest, "sort order must be zero or greater")
|
||||
return
|
||||
}
|
||||
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
if err := h.repository.UpdateSortOrder(c.Request.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
view, err := h.view(r.Context(), item, true)
|
||||
item, _ := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
view, err := h.view(c.Request.Context(), item, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URL")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": view})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": view})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Delete godoc
|
||||
// @Summary Delete media or cancel an upload
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/uploads/{id} [delete]
|
||||
// @Router /api/media/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.Delete(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.Delete(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
|
||||
if key != "" && key != item.ExternalURL {
|
||||
_ = h.storage.Delete(r.Context(), key)
|
||||
_ = h.storage.Delete(c.Request.Context(), key)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Download godoc
|
||||
// @Summary Create a signed original media download URL
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/media/{id}/download [post]
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil || item.ProcessingStatus != StatusReady {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
url := item.ExternalURL
|
||||
if url == "" {
|
||||
url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
|
||||
url, err = h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download")
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": url})
|
||||
_ = h.repository.RecordDownload(c.Request.Context(), item.GalleryID, &item.ID, user.ID.String())
|
||||
writeJSON(c, http.StatusOK, map[string]string{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) {
|
||||
@@ -330,26 +403,24 @@ func parseID(value string) (uuid.UUID, error) {
|
||||
return uuid.Parse(value)
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *MinIO) EnsureBucket(ctx context.Context) error {
|
||||
origins = []string{"*"}
|
||||
}
|
||||
if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{
|
||||
ID: "northline-browser-uploads",
|
||||
ID: "studio-browser-uploads",
|
||||
AllowedOrigin: origins,
|
||||
AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"},
|
||||
AllowedHeader: []string{"*"},
|
||||
|
||||
+57
-1
@@ -24,7 +24,7 @@ services:
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin}
|
||||
MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
|
||||
MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:8081}
|
||||
ports:
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
@@ -36,6 +36,62 @@ services:
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
environment:
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable
|
||||
PORT: "8080"
|
||||
CORS_ORIGIN: "http://localhost:8081"
|
||||
SESSION_SECRET: "${SESSION_SECRET:-docker-compose-session-secret-change-me}"
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||
STORAGE_ENDPOINT: "minio:9000"
|
||||
STORAGE_ACCESS_KEY: "${STORAGE_ACCESS_KEY:-minioadmin}"
|
||||
STORAGE_SECRET_KEY: "${STORAGE_SECRET_KEY:-minioadmin}"
|
||||
STORAGE_BUCKET: "${STORAGE_BUCKET:-gallery-media}"
|
||||
STORAGE_USE_SSL: "${STORAGE_USE_SSL:-false}"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
migrate:
|
||||
build: ./backend
|
||||
command: ["migrate", "-dir", "/migrations"]
|
||||
environment:
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable
|
||||
volumes:
|
||||
- ./migrations:/migrations:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
seed:
|
||||
build: ./backend
|
||||
command: ["seed"]
|
||||
environment:
|
||||
DB_DRIVER: postgres
|
||||
DATABASE_URL: postgres://surprise:surprise_dev_password@postgres:5432/surprise?sslmode=disable
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_BASE_URL: ""
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-8081}:80"
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
@@ -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
@@ -8,7 +8,7 @@
|
||||
name="description"
|
||||
content="Beautiful private galleries for photographers and their clients."
|
||||
/>
|
||||
<title>Northline Delivery Studio</title>
|
||||
<title>Noah Bianchi Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import GalleriesPage from './pages/dashboard/GalleriesPage';
|
||||
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
|
||||
import PlaceholderPage from './pages/dashboard/PlaceholderPage';
|
||||
import DevPage from './pages/dashboard/DevPage';
|
||||
import SettingsPage from './pages/dashboard/SettingsPage';
|
||||
import NotFoundPage from './pages/NotFoundPage';
|
||||
|
||||
export default function App() {
|
||||
@@ -67,7 +68,7 @@ export default function App() {
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Storage"
|
||||
description="A calm view of every original, preview, and byte in your studio is on its way."
|
||||
description="Storage module unavailable."
|
||||
/>
|
||||
</RequireAuth>
|
||||
}
|
||||
@@ -76,10 +77,7 @@ export default function App() {
|
||||
path="/dashboard/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<PlaceholderPage
|
||||
title="Settings"
|
||||
description="Your studio identity and delivery defaults will have a home here soon."
|
||||
/>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,16 +6,12 @@ export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
<div className="auth-shell__texture" aria-hidden="true" />
|
||||
<div className="auth-shell__brand">
|
||||
<span className="platform-mark">N</span>
|
||||
<span>Northline</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</div>
|
||||
<div className="auth-shell__aside">
|
||||
<p className="platform-kicker">The work deserves a beautiful handoff.</p>
|
||||
<h1 className="platform-display">
|
||||
Deliver the
|
||||
<br />
|
||||
<em>feeling.</em>
|
||||
</h1>
|
||||
<p>Private galleries for the photographs people keep forever.</p>
|
||||
<p className="platform-kicker">Private delivery system</p>
|
||||
<h1 className="platform-display">Studio Access</h1>
|
||||
<p>Photographer gallery console for client delivery.</p>
|
||||
</div>
|
||||
<div className="auth-shell__panel">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -26,8 +26,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>
|
||||
Northline
|
||||
<small>delivery studio</small>
|
||||
Noah Bianchi
|
||||
<small>photography studio</small>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="studio-sidebar__label">Workspace</div>
|
||||
@@ -48,8 +48,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<div className="studio-sidebar__note">
|
||||
<span className="studio-sidebar__note-mark">+</span>
|
||||
<span>
|
||||
<strong>Make it memorable.</strong>
|
||||
<small>Your work deserves a proper handoff.</small>
|
||||
<strong>System Status</strong>
|
||||
<small>Delivery console active.</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="studio-account">
|
||||
@@ -73,7 +73,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<div className="studio-mobilebar">
|
||||
<Link className="studio-logo" to="/dashboard">
|
||||
<span className="studio-logo__mark">N</span>
|
||||
<span>Northline</span>
|
||||
<span>Noah Bianchi</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -65,7 +65,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
'northline:last-upload-error',
|
||||
'studio:last-upload-error',
|
||||
JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }),
|
||||
);
|
||||
} catch {
|
||||
@@ -117,7 +117,7 @@ export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzone
|
||||
<span className="upload-zone__orb" aria-hidden="true">
|
||||
+
|
||||
</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">
|
||||
or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV
|
||||
</span>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
const cover = gallery.cover || photos[0];
|
||||
const theme = gallery.themeConfig || {};
|
||||
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]);
|
||||
|
||||
@@ -109,8 +109,8 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
>
|
||||
{gallery.preview && (
|
||||
<div className="preview-ribbon">
|
||||
<span>Preview mode</span>
|
||||
<span>This is how your clients will see it</span>
|
||||
<span>Preview Mode</span>
|
||||
<span>Client View</span>
|
||||
</div>
|
||||
)}
|
||||
<header className="client-gallery__nav">
|
||||
@@ -146,7 +146,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
<main>
|
||||
<section className="client-hero">
|
||||
<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>
|
||||
{gallery.description && (
|
||||
<p className="client-hero__description">{gallery.description}</p>
|
||||
@@ -161,21 +161,21 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="client-hero__cover-caption">
|
||||
<span>Open to remember</span>
|
||||
<span>Cover Image</span>
|
||||
<span>{items.length} pieces</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-hero__scroll">
|
||||
<span /> Scroll to wander
|
||||
<span /> Scroll for media
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="client-work-section">
|
||||
<div className="client-section-heading">
|
||||
<div>
|
||||
<p className="client-kicker">The collection</p>
|
||||
<p className="client-kicker">Gallery Media</p>
|
||||
<h2 className="client-display">
|
||||
The day, <em>held still.</em>
|
||||
Photos <em>& video.</em>
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
@@ -186,7 +186,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
{items.length === 0 ? (
|
||||
<div className="client-empty">
|
||||
<span>+</span>
|
||||
<p>Your gallery is still being arranged.</p>
|
||||
<p>No media uploaded.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="client-media-grid">
|
||||
@@ -206,13 +206,13 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
|
||||
<section className="client-download-section">
|
||||
<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">
|
||||
The moments are
|
||||
Download
|
||||
<br />
|
||||
<em>yours to keep.</em>
|
||||
<em>originals.</em>
|
||||
</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>
|
||||
{gallery.downloadsEnabled !== false && (
|
||||
<button
|
||||
@@ -238,7 +238,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Your ZIP is ready <span aria-hidden="true">↗</span>
|
||||
ZIP ready <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
)}
|
||||
{downloadJob?.status === 'FAILED' && (
|
||||
@@ -254,7 +254,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
|
||||
<div>
|
||||
<strong>{branding.studioName || 'Your studio'}</strong>
|
||||
<small>{branding.tagline || 'Photographs for keeps.'}</small>
|
||||
<small>{branding.tagline || 'Client Delivery'}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-footer__links">
|
||||
@@ -269,7 +269,7 @@ export function ClientGallery({ gallery }: ClientGalleryProps) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className="client-footer__credit">Delivered with intention</span>
|
||||
<span className="client-footer__credit">Noah Bianchi</span>
|
||||
</footer>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -27,7 +27,6 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
|
||||
return (
|
||||
<div className="client-lock-screen">
|
||||
<div className="client-lock-screen__glow" aria-hidden="true" />
|
||||
<div className="client-lock-screen__brand">
|
||||
<span className="client-monogram">
|
||||
{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>
|
||||
<p className="client-kicker">A private delivery</p>
|
||||
<p className="client-kicker">Protected Gallery</p>
|
||||
<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}>
|
||||
<label className="sr-only" htmlFor="gallery-password">
|
||||
Gallery password
|
||||
@@ -65,7 +64,7 @@ export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
|
||||
</form>
|
||||
{error && <p className="client-form-error">{error}</p>}
|
||||
</div>
|
||||
<p className="client-lock-screen__footer">The work is waiting inside.</p>
|
||||
<p className="client-lock-screen__footer">Access required.</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">↗</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">✦</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">↗</span>
|
||||
</button>
|
||||
</div>
|
||||
</Background>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">↗</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
“
|
||||
</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">
|
||||
▶
|
||||
</span>
|
||||
<span>Your video goes here</span>
|
||||
</div>
|
||||
)}
|
||||
{item.text && <p>{item.text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-26
@@ -1,4 +1,3 @@
|
||||
import type { Gift } from '../types/gift';
|
||||
import type { DevDiagnostics, DevStorageCheck } from '../types/dev';
|
||||
import type {
|
||||
DownloadJob,
|
||||
@@ -54,31 +53,6 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<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 {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -110,6 +84,21 @@ export async function logout(): Promise<void> {
|
||||
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[]> {
|
||||
const response = await request<{ galleries: GallerySummary[] }>('/api/galleries');
|
||||
return response.galleries;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -34,17 +34,13 @@ export default function LoginPage() {
|
||||
<AuthLayout>
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__heading">
|
||||
<p className="platform-kicker platform-kicker--accent">Welcome back</p>
|
||||
<h2 className="platform-display">
|
||||
Your work,
|
||||
<br />
|
||||
<em>waiting.</em>
|
||||
</h2>
|
||||
<p>Sign in to keep shaping the way your clients experience their photographs.</p>
|
||||
<p className="platform-kicker platform-kicker--accent">01 / Authenticate</p>
|
||||
<h2 className="platform-display">Sign In</h2>
|
||||
<p>Photographer console access.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Email address
|
||||
Email:
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
@@ -54,7 +50,7 @@ export default function LoginPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
Password:
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -69,12 +65,12 @@ export default function LoginPage() {
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Opening studio...' : 'Sign in'}</span>
|
||||
<span>{submitting ? 'Signing in...' : 'Sign In'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<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>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -4,13 +4,13 @@ export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="client-error client-error--not-found">
|
||||
<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">
|
||||
This link took
|
||||
Gallery
|
||||
<br />
|
||||
<em>a wrong turn.</em>
|
||||
<em>not found.</em>
|
||||
</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">
|
||||
Return to studio <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ClientLoading() {
|
||||
return (
|
||||
<div className="client-loading">
|
||||
<span className="client-loading__mark">N</span>
|
||||
<p>Preparing your gallery</p>
|
||||
<p>Loading gallery...</p>
|
||||
<span className="client-loading__line" />
|
||||
</div>
|
||||
);
|
||||
@@ -54,11 +54,11 @@ export function ClientError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="client-error">
|
||||
<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">
|
||||
This gallery is
|
||||
Unable to load
|
||||
<br />
|
||||
<em>out of reach.</em>
|
||||
<em>gallery.</em>
|
||||
</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
@@ -33,17 +33,13 @@ export default function RegisterPage() {
|
||||
<AuthLayout>
|
||||
<div className="auth-card">
|
||||
<div className="auth-card__heading">
|
||||
<p className="platform-kicker platform-kicker--accent">Start your studio</p>
|
||||
<h2 className="platform-display">
|
||||
Make the handoff
|
||||
<br />
|
||||
<em>matter.</em>
|
||||
</h2>
|
||||
<p>Create a private home for the work your clients have been waiting to see.</p>
|
||||
<p className="platform-kicker platform-kicker--accent">01 / Provision</p>
|
||||
<h2 className="platform-display">New Studio</h2>
|
||||
<p>Create a private delivery workspace.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={submit}>
|
||||
<label>
|
||||
Studio or photographer name
|
||||
Studio name:
|
||||
<input
|
||||
autoComplete="name"
|
||||
value={name}
|
||||
@@ -52,7 +48,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email address
|
||||
Email:
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
@@ -62,7 +58,7 @@ export default function RegisterPage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
Password:
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
@@ -78,12 +74,12 @@ export default function RegisterPage() {
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
<span>{submitting ? 'Creating studio...' : 'Create account'}</span>
|
||||
<span>{submitting ? 'Creating account...' : 'Create Account'}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-card__footer">
|
||||
Already have an account? <Link to="/login">Sign in</Link>
|
||||
Existing access? <Link to="/login">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
|
||||
@@ -43,43 +43,39 @@ export default function DashboardPage() {
|
||||
<div className="studio-page">
|
||||
<header className="studio-page__header">
|
||||
<div>
|
||||
<p className="studio-kicker">{user?.name || 'Studio'} / overview</p>
|
||||
<h1 className="studio-display">
|
||||
Make the handoff <em>matter.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
Everything your clients need, in one beautiful place.
|
||||
</p>
|
||||
<p className="studio-kicker">{user?.name || 'Studio'} / Overview</p>
|
||||
<h1 className="studio-display">Dashboard</h1>
|
||||
<p className="studio-page__lede">Active delivery workspace.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span>New Gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<section className="studio-stats" aria-label="Studio overview">
|
||||
<StudioStat
|
||||
label="Galleries"
|
||||
label="Total Galleries"
|
||||
value={String(galleries.length).padStart(2, '0')}
|
||||
note="All your work, in one place"
|
||||
note="Gallery Buffer"
|
||||
accent="coral"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Published"
|
||||
value={String(published).padStart(2, '0')}
|
||||
note="Currently out in the world"
|
||||
note="Live Deliveries"
|
||||
accent="violet"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Photographs"
|
||||
value={String(photos).padStart(2, '0')}
|
||||
note="Ready to be remembered"
|
||||
note="Media Records"
|
||||
accent="gold"
|
||||
/>
|
||||
<StudioStat
|
||||
label="Storage used"
|
||||
label="Storage Used"
|
||||
value={formatBytes(storage)}
|
||||
note="Across every gallery"
|
||||
note="Object Storage"
|
||||
accent="ink"
|
||||
/>
|
||||
</section>
|
||||
@@ -87,11 +83,11 @@ export default function DashboardPage() {
|
||||
<section className="studio-section studio-section--recent">
|
||||
<div className="studio-section__heading">
|
||||
<div>
|
||||
<p className="studio-kicker">Your latest work</p>
|
||||
<h2 className="studio-heading">Recent galleries</h2>
|
||||
<p className="studio-kicker">06 / Recent activity</p>
|
||||
<h2 className="studio-heading">Recent Galleries</h2>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries">
|
||||
View all galleries <span aria-hidden="true">↗</span>
|
||||
View All <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
{error && <p className="studio-alert studio-alert--error">{error}</p>}
|
||||
@@ -105,11 +101,11 @@ export default function DashboardPage() {
|
||||
<div className="studio-empty">
|
||||
<span className="studio-empty__mark">+</span>
|
||||
<div>
|
||||
<h3>Your first gallery starts here.</h3>
|
||||
<p>Give finished work a place that feels as considered as the work itself.</p>
|
||||
<h3>No galleries yet</h3>
|
||||
<p>No galleries. Create one to start.</p>
|
||||
</div>
|
||||
<Link className="inline-link" to="/dashboard/galleries/new">
|
||||
Create a gallery <span aria-hidden="true">↗</span>
|
||||
Create Gallery <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DevPage() {
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
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);
|
||||
} catch {
|
||||
setUploadError(null);
|
||||
@@ -70,12 +70,8 @@ export default function DevPage() {
|
||||
<header className="studio-page__header studio-page__header--compact">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / diagnostics</p>
|
||||
<h1 className="studio-display">
|
||||
The <em>workbench.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
Useful, non-secret signals for local development and upload debugging.
|
||||
</p>
|
||||
<h1 className="studio-display">Dev Console</h1>
|
||||
<p className="studio-page__lede">Local runtime diagnostics and storage checks.</p>
|
||||
</div>
|
||||
<div className="dev-page__actions">
|
||||
<button
|
||||
@@ -218,7 +214,7 @@ export default function DevPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.localStorage.removeItem('northline:last-upload-error');
|
||||
window.localStorage.removeItem('studio:last-upload-error');
|
||||
setUploadError(null);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -35,16 +35,12 @@ export default function GalleriesPage() {
|
||||
<div className="studio-page">
|
||||
<header className="studio-page__header studio-page__header--compact">
|
||||
<div>
|
||||
<p className="studio-kicker">Workspace / library</p>
|
||||
<h1 className="studio-display">
|
||||
Your <em>galleries.</em>
|
||||
</h1>
|
||||
<p className="studio-page__lede">
|
||||
The places where your finished work becomes a shared memory.
|
||||
</p>
|
||||
<p className="studio-kicker">Workspace / Galleries</p>
|
||||
<h1 className="studio-display">Galleries</h1>
|
||||
<p className="studio-page__lede">All delivery records.</p>
|
||||
</div>
|
||||
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
|
||||
<span>New gallery</span>
|
||||
<span>New Gallery</span>
|
||||
<span aria-hidden="true">+</span>
|
||||
</Link>
|
||||
</header>
|
||||
@@ -59,13 +55,11 @@ export default function GalleriesPage() {
|
||||
<div className="studio-empty studio-empty--large">
|
||||
<span className="studio-empty__mark">+</span>
|
||||
<div>
|
||||
<h3>A quiet room, waiting.</h3>
|
||||
<p>
|
||||
Create a gallery and give your next client a delivery experience they will remember.
|
||||
</p>
|
||||
<h3>No galleries yet</h3>
|
||||
<p>No galleries. Create one to start.</p>
|
||||
</div>
|
||||
<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">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function GalleryEditorPage() {
|
||||
<h1 className="studio-display">
|
||||
{isNew ? (
|
||||
<>
|
||||
A new <em>story.</em>
|
||||
New <em>gallery.</em>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -306,9 +306,9 @@ export default function GalleryEditorPage() {
|
||||
<div className="editor-maincol">
|
||||
<section className="editor-section editor-section--first">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">01 / The introduction</p>
|
||||
<h2>Give it a name.</h2>
|
||||
<p>This is the first thing your client will see.</p>
|
||||
<p className="studio-kicker">01 / Gallery_Metadata</p>
|
||||
<h2>Gallery_Info</h2>
|
||||
<p>Title and client-facing description.</p>
|
||||
</div>
|
||||
<div className="editor-fields editor-fields--two">
|
||||
<label>
|
||||
@@ -331,11 +331,11 @@ export default function GalleryEditorPage() {
|
||||
</label>
|
||||
</div>
|
||||
<label className="editor-fields__full">
|
||||
A note about this gallery
|
||||
Description:
|
||||
<textarea
|
||||
value={draft.description}
|
||||
onChange={(event) => setField('description', event.target.value)}
|
||||
placeholder="A few words to set the scene..."
|
||||
placeholder="Optional gallery description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
@@ -343,8 +343,8 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">02 / The work</p>
|
||||
<h2>Bring it all in.</h2>
|
||||
<p className="studio-kicker">02 / Media_Buffer</p>
|
||||
<h2>Upload_Files</h2>
|
||||
<p>
|
||||
Originals stay private in object storage. Previews are prepared in the background.
|
||||
</p>
|
||||
@@ -382,9 +382,7 @@ export default function GalleryEditorPage() {
|
||||
</div>
|
||||
)}
|
||||
{gallery && gallery.media.length === 0 && (
|
||||
<div className="editor-media-empty">
|
||||
Your uploaded photographs will take center stage here.
|
||||
</div>
|
||||
<div className="editor-media-empty">No media uploaded.</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -392,8 +390,8 @@ export default function GalleryEditorPage() {
|
||||
<aside className="editor-aside">
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">03 / Client controls</p>
|
||||
<h2>Set the boundaries.</h2>
|
||||
<p className="studio-kicker">03 / Client_Control</p>
|
||||
<h2>Access_Settings</h2>
|
||||
</div>
|
||||
<div className="toggle-list">
|
||||
<Toggle
|
||||
@@ -451,8 +449,8 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">04 / The atmosphere</p>
|
||||
<h2>Make it feel like you.</h2>
|
||||
<p className="studio-kicker">04 / Gallery_Config</p>
|
||||
<h2>Appearance_Module</h2>
|
||||
</div>
|
||||
<div className="choice-label">Mode</div>
|
||||
<div className="choice-row">
|
||||
@@ -512,15 +510,15 @@ export default function GalleryEditorPage() {
|
||||
|
||||
<section className="editor-section editor-section--aside">
|
||||
<div className="editor-section__heading">
|
||||
<p className="studio-kicker">05 / Your signature</p>
|
||||
<h2>Leave your mark.</h2>
|
||||
<p className="studio-kicker">05 / Branding_Config</p>
|
||||
<h2>Studio_Identity</h2>
|
||||
</div>
|
||||
<label className="editor-field-single">
|
||||
Studio name{' '}
|
||||
<input
|
||||
value={draft.studioName}
|
||||
onChange={(event) => setField('studioName', event.target.value)}
|
||||
placeholder={user?.name || 'Your studio'}
|
||||
placeholder={user?.name || 'Studio name'}
|
||||
/>
|
||||
</label>
|
||||
<label className="editor-field-single">
|
||||
@@ -528,7 +526,7 @@ export default function GalleryEditorPage() {
|
||||
<input
|
||||
value={draft.tagline}
|
||||
onChange={(event) => setField('tagline', event.target.value)}
|
||||
placeholder="Photographs for keeps."
|
||||
placeholder="Studio tagline"
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+970
-457
File diff suppressed because it is too large
Load Diff
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user