ai slop ah

This commit is contained in:
2026-08-22 17:27:55 +02:00
parent 6a5bb1d699
commit dc124d0d77
64 changed files with 7308 additions and 2448 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /usr/bin/server ./cmd/server
RUN go build -o /usr/bin/migrate ./cmd/migrate
RUN go build -o /usr/bin/seed ./cmd/seed
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /usr/bin/server /usr/bin/migrate /usr/bin/seed /usr/bin/
EXPOSE 8080
CMD ["server"]
+3 -3
View File
@@ -29,7 +29,7 @@ func main() {
if err := seed(ctx, database); err != nil {
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
View File
@@ -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))
}
}
+1828
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -3,36 +3,74 @@ module github.com/example/sndit/backend
go 1.24.0
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
View File
@@ -1,12 +1,56 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.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=
+160 -31
View File
@@ -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)
}
+47
View File
@@ -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
}
+59 -58
View File
@@ -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)
}
+19 -2
View File
@@ -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)
+41 -25
View File
@@ -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)
}
+81 -49
View File
@@ -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)
}
+274 -150
View File
@@ -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 &copy
}
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)
-73
View File
@@ -1,73 +0,0 @@
package gifts
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift)
return mux
}
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "invalid gift slug")
return
}
gift, err := h.service.GetPublicGift(r.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "gift not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gift")
return
}
writeJSON(w, http.StatusOK, gift)
}
func validSlug(slug string) bool {
if len(slug) == 0 || len(slug) > 100 {
return false
}
for index, character := range slug {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') ||
(character == '-' && index > 0 && index < len(slug)-1) {
continue
}
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
-125
View File
@@ -1,125 +0,0 @@
package gifts
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type fakeStore struct {
gift PublicGift
err error
}
func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) {
return f.gift, f.err
}
func TestGetGiftReturnsPublicRepresentation(t *testing.T) {
giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
service := NewService(fakeStore{gift: PublicGift{
ID: giftID,
Slug: "demo",
RecipientName: "Anna",
SenderName: "Alex",
Title: "A little surprise for you",
IntroMessage: "I made something for you.",
RevealMessage: "A beautiful final note.",
Items: []PublicGiftItem{{
ID: itemID,
Type: "text",
Title: "A note",
Text: "Hello",
SortOrder: 1,
}},
}})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("unexpected content type: %q", contentType)
}
var response PublicGift
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RecipientName != "Anna" || len(response.Items) != 1 {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestGetGiftReturnsNotFound(t *testing.T) {
service := NewService(fakeStore{err: ErrNotFound})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftHidesStoreErrors(t *testing.T) {
service := NewService(fakeStore{err: errors.New("database connection lost")})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftRejectsInvalidSlug(t *testing.T) {
service := NewService(fakeStore{})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestHealthReturnsOK(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" {
t.Fatalf("unexpected health body: %q", body)
}
}
func TestServiceRejectsEmptySlug(t *testing.T) {
service := NewService(fakeStore{})
_, err := service.GetPublicGift(context.Background(), "")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
-28
View File
@@ -1,28 +0,0 @@
package gifts
import (
"encoding/json"
"github.com/google/uuid"
)
type PublicGift struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
RecipientName string `json:"recipientName"`
SenderName string `json:"senderName"`
Title string `json:"title"`
IntroMessage string `json:"introMessage"`
RevealMessage string `json:"revealMessage"`
Items []PublicGiftItem `json:"items"`
}
type PublicGiftItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
MediaURL string `json:"mediaUrl,omitempty"`
SortOrder int `json:"sortOrder"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
-97
View File
@@ -1,97 +0,0 @@
package gifts
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) {
var gift PublicGift
err := r.db.QueryRowContext(ctx, `
SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message
FROM gifts
WHERE slug = $1 AND status = 'published'
`, slug).Scan(
&gift.ID,
&gift.Slug,
&gift.RecipientName,
&gift.SenderName,
&gift.Title,
&gift.IntroMessage,
&gift.RevealMessage,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("find gift: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, title, text, media_url, sort_order, metadata
FROM gift_items
WHERE gift_id = $1
ORDER BY sort_order ASC, id ASC
`, gift.ID)
if err != nil {
return PublicGift{}, fmt.Errorf("find gift items: %w", err)
}
defer rows.Close()
gift.Items = make([]PublicGiftItem, 0)
for rows.Next() {
var (
item PublicGiftItem
title sql.NullString
text sql.NullString
mediaURL sql.NullString
metadata []byte
)
if err := rows.Scan(
&item.ID,
&item.Type,
&title,
&text,
&mediaURL,
&item.SortOrder,
&metadata,
); err != nil {
return PublicGift{}, fmt.Errorf("scan gift item: %w", err)
}
item.Title = title.String
item.Text = text.String
item.MediaURL = mediaURL.String
if len(metadata) == 0 {
item.Metadata = []byte(`{}`)
} else {
item.Metadata = metadata
}
gift.Items = append(gift.Items, item)
}
if err := rows.Err(); err != nil {
return PublicGift{}, fmt.Errorf("iterate gift items: %w", err)
}
return gift, nil
}
// Store is the read contract used by the service. Keeping it small makes the
// HTTP layer straightforward to test without a running database.
type Store interface {
GetPublicBySlug(context.Context, string) (PublicGift, error)
}
var _ Store = (*Repository)(nil)
@@ -1,57 +0,0 @@
package gifts
import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRepositoryReadsSQLiteMigrations(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite")
entries, err := os.ReadDir(migrationDirectory)
if err != nil {
t.Fatalf("read sqlite migrations: %v", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name()))
if err != nil {
t.Fatalf("read migration %s: %v", entry.Name(), err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply migration %s: %v", entry.Name(), err)
}
}
gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo")
if err != nil {
t.Fatalf("load demo gift: %v", err)
}
if gift.RecipientName != "Anna" || gift.SenderName != "Alex" {
t.Fatalf("unexpected gift: %+v", gift)
}
if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 {
t.Fatalf("unexpected gift items: %+v", gift.Items)
}
}
-33
View File
@@ -1,33 +0,0 @@
package gifts
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("gift not found")
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) {
if slug == "" {
return PublicGift{}, ErrNotFound
}
gift, err := s.store.GetPublicBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("get public gift: %w", err)
}
return gift, nil
}
+163 -92
View File
@@ -12,6 +12,7 @@ import (
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/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)
}
+1 -1
View File
@@ -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{"*"},