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
+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{"*"},