ai slop ah
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -15,11 +17,16 @@ func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/auth/register", h.Register)
|
||||
mux.HandleFunc("POST /api/auth/login", h.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", h.Logout)
|
||||
mux.HandleFunc("GET /api/auth/me", h.Me)
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter) {
|
||||
router.POST("/api/auth/register", h.Register)
|
||||
router.POST("/api/auth/login", h.Login)
|
||||
router.POST("/api/auth/logout", h.Logout)
|
||||
router.GET("/api/auth/me", h.Me)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.PATCH("/api/auth/me", require, h.UpdateMe)
|
||||
router.POST("/api/auth/password", require, h.ChangePassword)
|
||||
}
|
||||
|
||||
type credentialsRequest struct {
|
||||
@@ -28,66 +35,188 @@ type credentialsRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
type profileRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type passwordRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a photographer account
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body credentialsRequest true "Account details"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/auth/register [post]
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var request credentialsRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name)
|
||||
user, err := h.service.Register(c.Request.Context(), request.Email, request.Password, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.service.SetSession(w, user)
|
||||
writeJSON(w, http.StatusCreated, map[string]User{"user": user})
|
||||
h.service.SetSession(c, user)
|
||||
writeJSON(c, http.StatusCreated, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
// Login godoc
|
||||
// @Summary Sign in a photographer
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body credentialsRequest true "Account credentials"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/login [post]
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var request credentialsRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
user, err := h.service.Login(r.Context(), request.Email, request.Password)
|
||||
user, err := h.service.Login(c.Request.Context(), request.Email, request.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
|
||||
return
|
||||
}
|
||||
h.service.SetSession(w, user)
|
||||
writeJSON(w, http.StatusOK, map[string]User{"user": user})
|
||||
h.service.SetSession(c, user)
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
|
||||
h.service.ClearSession(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
// Logout godoc
|
||||
// @Summary Sign out the current photographer
|
||||
// @Tags authentication
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /api/auth/logout [post]
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
h.service.ClearSession(c)
|
||||
writeJSON(c, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.service.UserFromRequest(r.Context(), r)
|
||||
// Me godoc
|
||||
// @Summary Get the current photographer
|
||||
// @Tags authentication
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/me [get]
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
user, err := h.service.UserFromRequest(c.Request.Context(), c.Request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]User{"user": user})
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": user})
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
|
||||
// UpdateMe godoc
|
||||
// @Summary Update the current photographer profile
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body profileRequest true "Profile details"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 409 {object} map[string]string
|
||||
// @Router /api/auth/me [patch]
|
||||
func (h *Handler) UpdateMe(c *gin.Context) {
|
||||
user, ok := UserFromContext(c)
|
||||
if !ok {
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
var request profileRequest
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
updated, err := h.service.UpdateProfile(c.Request.Context(), user.ID, request.Email, request.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
writeJSON(c, http.StatusConflict, map[string]string{"error": "email is already registered"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "enter ") || strings.HasPrefix(err.Error(), "name ") {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update profile"})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusOK, map[string]User{"user": updated})
|
||||
}
|
||||
|
||||
// ChangePassword godoc
|
||||
// @Summary Change the current photographer password
|
||||
// @Tags authentication
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body passwordRequest true "Password details"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/auth/password [post]
|
||||
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||
user, ok := UserFromContext(c)
|
||||
if !ok {
|
||||
writeJSON(c, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
var request passwordRequest
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if err := h.service.ChangePassword(c.Request.Context(), user.ID, request.CurrentPassword, request.NewPassword); err != nil {
|
||||
if errors.Is(err, ErrCurrentPassword) {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": "current password is incorrect"})
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(err.Error(), "new password") {
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]string{"error": "could not update password"})
|
||||
return
|
||||
}
|
||||
writeJSON(c, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeJSON(c, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
writeJSON(c, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -66,3 +66,50 @@ func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindByIDWithPassword(ctx context.Context, id uuid.UUID) (storedUser, error) {
|
||||
var user storedUser
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, name, password_hash
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedUser{}, sql.ErrNoRows
|
||||
}
|
||||
if err != nil {
|
||||
return storedUser{}, fmt.Errorf("find user credentials: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUser(ctx context.Context, id uuid.UUID, email, name string) (User, error) {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET email = $1, name = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
`, strings.ToLower(strings.TrimSpace(email)), strings.TrimSpace(name), id)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
return User{}, ErrEmailTaken
|
||||
}
|
||||
return User{}, fmt.Errorf("update user: %w", err)
|
||||
}
|
||||
return r.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
`, passwordHash, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update password: %w", err)
|
||||
}
|
||||
count, err := result.RowsAffected()
|
||||
if err != nil || count == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -29,6 +30,7 @@ const (
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidSession = errors.New("invalid session")
|
||||
ErrCurrentPassword = errors.New("current password is incorrect")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -78,33 +80,47 @@ func (s *Service) Login(ctx context.Context, email, password string) (User, erro
|
||||
return user.User, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetSession(w http.ResponseWriter, user User) {
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, email, name string) (User, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
name = strings.TrimSpace(name)
|
||||
if !strings.Contains(email, "@") || len(email) > 254 {
|
||||
return User{}, fmt.Errorf("enter a valid email address")
|
||||
}
|
||||
if name == "" || len(name) > 120 {
|
||||
return User{}, fmt.Errorf("name is required")
|
||||
}
|
||||
return s.repository.UpdateUser(ctx, userID, email, name)
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
if len(newPassword) < 8 {
|
||||
return fmt.Errorf("new password must be at least 8 characters")
|
||||
}
|
||||
user, err := s.repository.FindByIDWithPassword(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)); err != nil {
|
||||
return ErrCurrentPassword
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash new password: %w", err)
|
||||
}
|
||||
return s.repository.UpdatePassword(ctx, userID, string(hash))
|
||||
}
|
||||
|
||||
func (s *Service) SetSession(c *gin.Context, user User) {
|
||||
payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()}
|
||||
token, err := s.signJSON(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, sessionCookieName, token, int(sessionDuration.Seconds()), true)
|
||||
}
|
||||
|
||||
func (s *Service) ClearSession(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
func (s *Service) ClearSession(c *gin.Context) {
|
||||
s.setCookie(c, sessionCookieName, "", -1, true)
|
||||
}
|
||||
|
||||
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) {
|
||||
@@ -131,61 +147,47 @@ type contextKey string
|
||||
|
||||
const userContextKey contextKey = "authenticated-user"
|
||||
|
||||
func (s *Service) Require(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := s.UserFromRequest(r.Context(), r)
|
||||
func (s *Service) Require() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
user, err := s.UserFromRequest(c.Request.Context(), c.Request)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
|
||||
})
|
||||
c.Set(userContextKey, user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (User, bool) {
|
||||
user, ok := ctx.Value(userContextKey).(User)
|
||||
return user, ok
|
||||
func UserFromContext(c *gin.Context) (User, bool) {
|
||||
value, ok := c.Get(userContextKey)
|
||||
user, valid := value.(User)
|
||||
return user, ok && valid
|
||||
}
|
||||
|
||||
func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(visitorCookieName); err == nil {
|
||||
func (s *Service) EnsureVisitor(c *gin.Context) string {
|
||||
if cookie, err := c.Request.Cookie(visitorCookieName); err == nil {
|
||||
if _, err := uuid.Parse(cookie.Value); err == nil {
|
||||
return cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
visitorID := uuid.NewString()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: visitorCookieName,
|
||||
Value: visitorID,
|
||||
Path: "/",
|
||||
MaxAge: int(365 * 24 * time.Hour / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, visitorCookieName, visitorID, int(365*24*time.Hour/time.Second), true)
|
||||
return visitorID
|
||||
}
|
||||
|
||||
func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) {
|
||||
func (s *Service) GrantGalleryAccess(c *gin.Context, slug string) {
|
||||
payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()}
|
||||
token, err := s.signJSON(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: accessCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(accessDuration.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
s.setCookie(c, accessCookieName, token, int(accessDuration.Seconds()), true)
|
||||
}
|
||||
|
||||
func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
|
||||
cookie, err := r.Cookie(accessCookieName)
|
||||
func (s *Service) HasGalleryAccess(c *gin.Context, slug string) bool {
|
||||
cookie, err := c.Request.Cookie(accessCookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -196,6 +198,11 @@ func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
|
||||
return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix()
|
||||
}
|
||||
|
||||
func (s *Service) setCookie(c *gin.Context, name, value string, maxAge int, httpOnly bool) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(name, value, maxAge, "/", "", s.secure, httpOnly)
|
||||
}
|
||||
|
||||
type sessionPayload struct {
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
@@ -235,9 +242,3 @@ func (s *Service) signature(value string) string {
|
||||
_, _ = hash.Write([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestRegisterLoginAndSession(t *testing.T) {
|
||||
@@ -33,7 +35,7 @@ func TestRegisterLoginAndSession(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio")
|
||||
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Noah Bianchi")
|
||||
if err != nil {
|
||||
t.Fatalf("register user: %v", err)
|
||||
}
|
||||
@@ -44,9 +46,24 @@ func TestRegisterLoginAndSession(t *testing.T) {
|
||||
if err != nil || loggedIn.ID != user.ID {
|
||||
t.Fatalf("login failed: user=%+v err=%v", loggedIn, err)
|
||||
}
|
||||
updated, err := service.UpdateProfile(ctx, user.ID, "updated@example.com", "Updated Studio")
|
||||
if err != nil || updated.Email != "updated@example.com" || updated.Name != "Updated Studio" {
|
||||
t.Fatalf("profile update failed: user=%+v err=%v", updated, err)
|
||||
}
|
||||
if err := service.ChangePassword(ctx, user.ID, "DemoPassword123!", "NewPassword123!"); err != nil {
|
||||
t.Fatalf("password update failed: %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "updated@example.com", "DemoPassword123!"); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("old password should be invalid, got %v", err)
|
||||
}
|
||||
if _, err := service.Login(ctx, "updated@example.com", "NewPassword123!"); err != nil {
|
||||
t.Fatalf("new password should work: %v", err)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
service.SetSession(recorder, user)
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
service.SetSession(ginContext, user)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
request.AddCookie(recorder.Result().Cookies()[0])
|
||||
fromSession, err := service.UserFromRequest(ctx, request)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -35,28 +35,36 @@ func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Ser
|
||||
return &Handler{db: db, storage: objectStorage, auth: authService, config: config}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics)))
|
||||
mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck)))
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/dev/diagnostics", require, h.Diagnostics)
|
||||
router.POST("/api/dev/storage-check", require, h.StorageCheck)
|
||||
}
|
||||
|
||||
func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
// Diagnostics godoc
|
||||
// @Summary Inspect local development dependencies
|
||||
// @Tags development
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/dev/diagnostics [get]
|
||||
func (h *Handler) Diagnostics(c *gin.Context) {
|
||||
user, _ := auth.UserFromContext(c)
|
||||
databaseError := ""
|
||||
databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
databaseContext, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
if err := h.db.PingContext(databaseContext); err != nil {
|
||||
databaseError = err.Error()
|
||||
}
|
||||
cancel()
|
||||
|
||||
storageError := ""
|
||||
storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
storageContext, storageCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
if err := h.storage.EnsureBucket(storageContext); err != nil {
|
||||
storageError = err.Error()
|
||||
}
|
||||
storageCancel()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
writeJSON(c, http.StatusOK, map[string]any{
|
||||
"environment": "development",
|
||||
"now": time.Now().UTC().Format(time.RFC3339),
|
||||
"user": map[string]string{
|
||||
@@ -84,33 +92,43 @@ func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.storage.EnsureBucket(r.Context()); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
|
||||
// StorageCheck godoc
|
||||
// @Summary Check MinIO storage read/write access
|
||||
// @Tags development
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]interface{}
|
||||
// @Failure 503 {object} map[string]interface{}
|
||||
// @Router /api/dev/storage-check [post]
|
||||
func (h *Handler) StorageCheck(c *gin.Context) {
|
||||
if err := h.storage.EnsureBucket(c.Request.Context()); err != nil {
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var randomBytes [12]byte
|
||||
if _, err := rand.Read(randomBytes[:]); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
|
||||
writeJSON(c, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:]))
|
||||
contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
|
||||
contents := "studio storage check " + time.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := h.storage.Put(c.Request.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
|
||||
return
|
||||
}
|
||||
info, statErr := h.storage.Stat(r.Context(), key)
|
||||
deleteErr := h.storage.Delete(r.Context(), key)
|
||||
info, statErr := h.storage.Stat(c.Request.Context(), key)
|
||||
deleteErr := h.storage.Delete(c.Request.Context(), key)
|
||||
if statErr != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
|
||||
return
|
||||
}
|
||||
if deleteErr != nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
|
||||
writeJSON(c, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
|
||||
}
|
||||
|
||||
func splitOrigins(value string) []string {
|
||||
@@ -123,8 +141,6 @@ func splitOrigins(value string) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package downloads
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/galleries"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -25,80 +25,114 @@ func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.
|
||||
return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll)
|
||||
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus)
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter) {
|
||||
router.POST("/api/public/galleries/:slug/media/:mediaId/download", h.Download)
|
||||
router.POST("/api/public/galleries/:slug/download-all", h.DownloadAll)
|
||||
router.GET("/api/public/galleries/:slug/download-all/:jobId", h.DownloadAllStatus)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// Download godoc
|
||||
// @Summary Download one public gallery media item
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/download [post]
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "downloads are disabled")
|
||||
return
|
||||
}
|
||||
mediaID, err := uuid.Parse(r.PathValue("mediaId"))
|
||||
mediaID, err := uuid.Parse(c.Param("mediaId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.media.GetByID(r.Context(), mediaID)
|
||||
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
||||
if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
url, err := h.downloadURL(r, item)
|
||||
url, err := h.downloadURL(c, item)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": url})
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
_ = h.media.RecordDownload(c.Request.Context(), record.ID, &mediaID, visitorID)
|
||||
writeJSON(c, http.StatusOK, map[string]string{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// DownloadAll godoc
|
||||
// @Summary Start a public gallery ZIP download
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Success 202 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/download-all [post]
|
||||
func (h *Handler) DownloadAll(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "gallery downloads are disabled")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
job, err := h.service.Create(r.Context(), record.ID, visitorID)
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
job, err := h.service.Create(c.Request.Context(), record.ID, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not start gallery download")
|
||||
writeError(c, http.StatusInternalServerError, "could not start gallery download")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
|
||||
writeJSON(c, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
|
||||
}
|
||||
|
||||
func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := h.publicRecord(r)
|
||||
// DownloadAllStatus godoc
|
||||
// @Summary Get public gallery ZIP download status
|
||||
// @Tags public downloads
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param jobId path string true "Download job UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/download-all/{jobId} [get]
|
||||
func (h *Handler) DownloadAllStatus(c *gin.Context) {
|
||||
record, err := h.publicRecord(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
|
||||
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
|
||||
writeError(c, http.StatusForbidden, "gallery downloads are disabled")
|
||||
return
|
||||
}
|
||||
jobID, err := uuid.Parse(r.PathValue("jobId"))
|
||||
jobID, err := uuid.Parse(c.Param("jobId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid download job id")
|
||||
writeError(c, http.StatusBadRequest, "invalid download job id")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID)
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
job, err := h.service.Get(c.Request.Context(), jobID, record.ID, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "download job not found")
|
||||
writeError(c, http.StatusNotFound, "download job not found")
|
||||
return
|
||||
}
|
||||
response := map[string]any{"jobId": job.ID.String(), "status": job.Status}
|
||||
@@ -106,40 +140,38 @@ func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
|
||||
response["error"] = job.Error
|
||||
}
|
||||
if job.Status == StatusReady {
|
||||
url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour)
|
||||
url, err := h.storage.CreateDownloadURL(c.Request.Context(), job.StorageKey, time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download URL")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download URL")
|
||||
return
|
||||
}
|
||||
response["url"] = url
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
writeJSON(c, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) {
|
||||
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
|
||||
func (h *Handler) publicRecord(c *gin.Context) (galleries.GalleryRecord, error) {
|
||||
record, err := h.galleries.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
|
||||
if err != nil || record.IsExpired() {
|
||||
return galleries.GalleryRecord{}, galleries.ErrNotFound
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
return galleries.GalleryRecord{}, galleries.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) {
|
||||
func (h *Handler) downloadURL(c *gin.Context, item media.Record) (string, error) {
|
||||
if item.ExternalURL != "" {
|
||||
return item.ExternalURL, nil
|
||||
}
|
||||
return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
|
||||
return h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,22 +35,22 @@ func NewHandler(repository *Repository, mediaRepository *media.Repository, objec
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List)))
|
||||
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create)))
|
||||
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get)))
|
||||
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update)))
|
||||
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish)))
|
||||
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish)))
|
||||
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview)))
|
||||
func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/galleries", require, h.List)
|
||||
router.POST("/api/galleries", require, h.Create)
|
||||
router.GET("/api/galleries/:id", require, h.Get)
|
||||
router.PATCH("/api/galleries/:id", require, h.Update)
|
||||
router.DELETE("/api/galleries/:id", require, h.Delete)
|
||||
router.POST("/api/galleries/:id/publish", require, h.Publish)
|
||||
router.POST("/api/galleries/:id/unpublish", require, h.Unpublish)
|
||||
router.GET("/api/galleries/:id/preview", require, h.Preview)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite)
|
||||
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite)
|
||||
func (h *Handler) RegisterPublicRoutes(router gin.IRouter) {
|
||||
router.GET("/api/public/galleries/:slug", h.Public)
|
||||
router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic)
|
||||
router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite)
|
||||
router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite)
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
@@ -78,15 +79,23 @@ type publicPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// List godoc
|
||||
// @Summary List owned galleries
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
|
||||
summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load galleries")
|
||||
writeError(c, http.StatusInternalServerError, "could not load galleries")
|
||||
return
|
||||
}
|
||||
for index := range summaries {
|
||||
@@ -97,97 +106,141 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
|
||||
summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Create godoc
|
||||
// @Summary Create a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body createRequest true "Gallery details"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
var request createRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
request.Title = strings.TrimSpace(request.Title)
|
||||
request.ClientName = strings.TrimSpace(request.ClientName)
|
||||
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
record, err := h.repository.Create(c.Request.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not create gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Get godoc
|
||||
// @Summary Get an owned gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [get]
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Preview godoc
|
||||
// @Summary Preview a gallery as a client
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/preview [get]
|
||||
func (h *Handler) Preview(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
|
||||
writeError(c, http.StatusInternalServerError, "could not build gallery preview")
|
||||
return
|
||||
}
|
||||
gallery.Preview = true
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Update godoc
|
||||
// @Summary Update a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Param request body map[string]interface{} true "Gallery changes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [patch]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
current, err := h.recordForUser(r, user.ID)
|
||||
current, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
var request updateRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,19 +270,19 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
input.Description = strings.TrimSpace(*request.Description)
|
||||
}
|
||||
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
if request.Password != nil {
|
||||
if strings.TrimSpace(*request.Password) == "" {
|
||||
input.ClearPassword = true
|
||||
} else if len(*request.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
writeError(c, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
return
|
||||
} else {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
|
||||
writeError(c, http.StatusInternalServerError, "could not secure gallery password")
|
||||
return
|
||||
}
|
||||
hashed := string(hash)
|
||||
@@ -257,7 +310,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
value := strings.TrimSpace(*request.ExpiresAt)
|
||||
if value != "" {
|
||||
if _, err := time.Parse(time.RFC3339, value); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
writeError(c, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -268,12 +321,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if value != "" {
|
||||
coverID, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid cover media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid cover media id")
|
||||
return
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil || cover.GalleryID != current.ID {
|
||||
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
writeError(c, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -281,182 +334,255 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(request.ThemeConfig) > 0 {
|
||||
if !json.Valid(request.ThemeConfig) {
|
||||
writeError(w, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.ThemeConfig = request.ThemeConfig
|
||||
}
|
||||
if len(request.BrandingConfig) > 0 {
|
||||
if !json.Valid(request.BrandingConfig) {
|
||||
writeError(w, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.BrandingConfig = request.BrandingConfig
|
||||
}
|
||||
|
||||
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
|
||||
record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery")
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusPublished)
|
||||
// Publish godoc
|
||||
// @Summary Publish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/publish [post]
|
||||
func (h *Handler) Publish(c *gin.Context) {
|
||||
h.setStatus(c, StatusPublished)
|
||||
}
|
||||
|
||||
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusDraft)
|
||||
// Unpublish godoc
|
||||
// @Summary Unpublish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/unpublish [post]
|
||||
func (h *Handler) Unpublish(c *gin.Context) {
|
||||
h.setStatus(c, StatusDraft)
|
||||
}
|
||||
|
||||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
func (h *Handler) setStatus(c *gin.Context, status string) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
|
||||
record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery status")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery status")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Delete godoc
|
||||
// @Summary Delete a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 204
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
id, err := pathUUID(r, "id")
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(w, err)
|
||||
if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// Public godoc
|
||||
// @Summary Get a published public gallery
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug} [get]
|
||||
func (h *Handler) Public(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
gallery := h.lockedPayload(record)
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
return
|
||||
}
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// AuthenticatePublic godoc
|
||||
// @Summary Authenticate to a password-protected gallery
|
||||
// @Tags public galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param request body publicPasswordRequest true "Gallery password"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/authenticate [post]
|
||||
func (h *Handler) AuthenticatePublic(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
var request publicPasswordRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
|
||||
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
|
||||
writeError(c, http.StatusUnauthorized, "incorrect gallery password")
|
||||
return
|
||||
}
|
||||
h.auth.GrantGalleryAccess(w, record.Slug)
|
||||
h.auth.GrantGalleryAccess(c, record.Slug)
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, true)
|
||||
// Favorite godoc
|
||||
// @Summary Favorite a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [post]
|
||||
func (h *Handler) Favorite(c *gin.Context) {
|
||||
h.setFavorite(c, true)
|
||||
}
|
||||
|
||||
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, false)
|
||||
// Unfavorite godoc
|
||||
// @Summary Remove a favorite from a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [delete]
|
||||
func (h *Handler) Unfavorite(c *gin.Context) {
|
||||
h.setFavorite(c, false)
|
||||
}
|
||||
|
||||
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(r)
|
||||
func (h *Handler) setFavorite(c *gin.Context, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.FavoritesEnabled {
|
||||
writeError(w, http.StatusForbidden, "favorites are disabled")
|
||||
writeError(c, http.StatusForbidden, "favorites are disabled")
|
||||
return
|
||||
}
|
||||
mediaID, err := pathUUID(r, "mediaId")
|
||||
mediaID, err := pathUUID(c, "mediaId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.media.GetByID(r.Context(), mediaID)
|
||||
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
||||
if err != nil || item.GalleryID != record.ID {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update favorite")
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(c, http.StatusInternalServerError, "could not update favorite")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
writeJSON(c, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
}
|
||||
|
||||
func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
|
||||
func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
|
||||
if err != nil || record.IsExpired() {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
func (h *Handler) publicPayload(ctx context.Context, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
items, err := h.media.ListByGallery(ctx, record.ID)
|
||||
if err != nil {
|
||||
return Public{}, err
|
||||
@@ -509,12 +635,12 @@ func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(r, "id")
|
||||
func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
return GalleryRecord{}, err
|
||||
}
|
||||
return h.repository.GetForUser(r.Context(), userID, id)
|
||||
return h.repository.GetForUser(c.Request.Context(), userID, id)
|
||||
}
|
||||
|
||||
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
|
||||
@@ -628,8 +754,8 @@ func newSlug(title string) string {
|
||||
return slug + "-" + suffix
|
||||
}
|
||||
|
||||
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(r.PathValue(name))
|
||||
func pathUUID(c *gin.Context, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid %s", name)
|
||||
}
|
||||
@@ -644,34 +770,32 @@ func stringPointer(value string) *string {
|
||||
return ©
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeGalleryError(w http.ResponseWriter, err error) {
|
||||
func writeGalleryError(c *gin.Context, err error) {
|
||||
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -40,13 +41,13 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
|
||||
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8)
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil {
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Noah Bianchi"}`); err != nil {
|
||||
t.Fatalf("insert gallery: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
@@ -61,12 +62,12 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterPublicRoutes(mux)
|
||||
router := gin.New()
|
||||
handler.RegisterPublicRoutes(router)
|
||||
|
||||
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(getRecorder, getRequest)
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
favoriteRequest.AddCookie(cookie)
|
||||
}
|
||||
favoriteRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
router.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
if favoriteRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
}
|
||||
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
repository := NewRepository(database)
|
||||
@@ -62,7 +62,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
FavoritesEnabled: true,
|
||||
DownloadAllEnabled: true,
|
||||
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Noah Bianchi"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update gallery: %v", err)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /health", h.Health)
|
||||
mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
if !validSlug(slug) {
|
||||
writeError(w, http.StatusBadRequest, "invalid gift slug")
|
||||
return
|
||||
}
|
||||
|
||||
gift, err := h.service.GetPublicGift(r.Context(), slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "gift not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not load gift")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, gift)
|
||||
}
|
||||
|
||||
func validSlug(slug string) bool {
|
||||
if len(slug) == 0 || len(slug) > 100 {
|
||||
return false
|
||||
}
|
||||
for index, character := range slug {
|
||||
if (character >= 'a' && character <= 'z') ||
|
||||
(character >= 'A' && character <= 'Z') ||
|
||||
(character >= '0' && character <= '9') ||
|
||||
(character == '-' && index > 0 && index < len(slug)-1) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
gift PublicGift
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) {
|
||||
return f.gift, f.err
|
||||
}
|
||||
|
||||
func TestGetGiftReturnsPublicRepresentation(t *testing.T) {
|
||||
giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
|
||||
itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
|
||||
service := NewService(fakeStore{gift: PublicGift{
|
||||
ID: giftID,
|
||||
Slug: "demo",
|
||||
RecipientName: "Anna",
|
||||
SenderName: "Alex",
|
||||
Title: "A little surprise for you",
|
||||
IntroMessage: "I made something for you.",
|
||||
RevealMessage: "A beautiful final note.",
|
||||
Items: []PublicGiftItem{{
|
||||
ID: itemID,
|
||||
Type: "text",
|
||||
Title: "A note",
|
||||
Text: "Hello",
|
||||
SortOrder: 1,
|
||||
}},
|
||||
}})
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
NewHandler(service).Routes().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||
t.Fatalf("unexpected content type: %q", contentType)
|
||||
}
|
||||
|
||||
var response PublicGift
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.RecipientName != "Anna" || len(response.Items) != 1 {
|
||||
t.Fatalf("unexpected response: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGiftReturnsNotFound(t *testing.T) {
|
||||
service := NewService(fakeStore{err: ErrNotFound})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).Routes().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", recorder.Code)
|
||||
}
|
||||
if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" {
|
||||
t.Fatalf("unexpected error body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGiftHidesStoreErrors(t *testing.T) {
|
||||
service := NewService(fakeStore{err: errors.New("database connection lost")})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).Routes().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", recorder.Code)
|
||||
}
|
||||
if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" {
|
||||
t.Fatalf("unexpected error body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGiftRejectsInvalidSlug(t *testing.T) {
|
||||
service := NewService(fakeStore{})
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).Routes().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReturnsOK(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
|
||||
NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", recorder.Code)
|
||||
}
|
||||
if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" {
|
||||
t.Fatalf("unexpected health body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsEmptySlug(t *testing.T) {
|
||||
service := NewService(fakeStore{})
|
||||
_, err := service.GetPublicGift(context.Background(), "")
|
||||
if err != ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PublicGift struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
RecipientName string `json:"recipientName"`
|
||||
SenderName string `json:"senderName"`
|
||||
Title string `json:"title"`
|
||||
IntroMessage string `json:"introMessage"`
|
||||
RevealMessage string `json:"revealMessage"`
|
||||
Items []PublicGiftItem `json:"items"`
|
||||
}
|
||||
|
||||
type PublicGiftItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
MediaURL string `json:"mediaUrl,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) {
|
||||
var gift PublicGift
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message
|
||||
FROM gifts
|
||||
WHERE slug = $1 AND status = 'published'
|
||||
`, slug).Scan(
|
||||
&gift.ID,
|
||||
&gift.Slug,
|
||||
&gift.RecipientName,
|
||||
&gift.SenderName,
|
||||
&gift.Title,
|
||||
&gift.IntroMessage,
|
||||
&gift.RevealMessage,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PublicGift{}, ErrNotFound
|
||||
}
|
||||
return PublicGift{}, fmt.Errorf("find gift: %w", err)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, type, title, text, media_url, sort_order, metadata
|
||||
FROM gift_items
|
||||
WHERE gift_id = $1
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`, gift.ID)
|
||||
if err != nil {
|
||||
return PublicGift{}, fmt.Errorf("find gift items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
gift.Items = make([]PublicGiftItem, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
item PublicGiftItem
|
||||
title sql.NullString
|
||||
text sql.NullString
|
||||
mediaURL sql.NullString
|
||||
metadata []byte
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&item.ID,
|
||||
&item.Type,
|
||||
&title,
|
||||
&text,
|
||||
&mediaURL,
|
||||
&item.SortOrder,
|
||||
&metadata,
|
||||
); err != nil {
|
||||
return PublicGift{}, fmt.Errorf("scan gift item: %w", err)
|
||||
}
|
||||
|
||||
item.Title = title.String
|
||||
item.Text = text.String
|
||||
item.MediaURL = mediaURL.String
|
||||
if len(metadata) == 0 {
|
||||
item.Metadata = []byte(`{}`)
|
||||
} else {
|
||||
item.Metadata = metadata
|
||||
}
|
||||
gift.Items = append(gift.Items, item)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return PublicGift{}, fmt.Errorf("iterate gift items: %w", err)
|
||||
}
|
||||
|
||||
return gift, nil
|
||||
}
|
||||
|
||||
// Store is the read contract used by the service. Keeping it small makes the
|
||||
// HTTP layer straightforward to test without a running database.
|
||||
type Store interface {
|
||||
GetPublicBySlug(context.Context, string) (PublicGift, error)
|
||||
}
|
||||
|
||||
var _ Store = (*Repository)(nil)
|
||||
@@ -1,57 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
)
|
||||
|
||||
func TestRepositoryReadsSQLiteMigrations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
database, err := appdb.New(ctx, "sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
_, sourceFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("find test source file")
|
||||
}
|
||||
migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite")
|
||||
entries, err := os.ReadDir(migrationDirectory)
|
||||
if err != nil {
|
||||
t.Fatalf("read sqlite migrations: %v", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", entry.Name(), err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
|
||||
t.Fatalf("apply migration %s: %v", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo")
|
||||
if err != nil {
|
||||
t.Fatalf("load demo gift: %v", err)
|
||||
}
|
||||
if gift.RecipientName != "Anna" || gift.SenderName != "Alex" {
|
||||
t.Fatalf("unexpected gift: %+v", gift)
|
||||
}
|
||||
if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 {
|
||||
t.Fatalf("unexpected gift items: %+v", gift.Items)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package gifts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("gift not found")
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
func NewService(store Store) *Service {
|
||||
return &Service{store: store}
|
||||
}
|
||||
|
||||
func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) {
|
||||
if slug == "" {
|
||||
return PublicGift{}, ErrNotFound
|
||||
}
|
||||
|
||||
gift, err := s.store.GetPublicBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return PublicGift{}, ErrNotFound
|
||||
}
|
||||
return PublicGift{}, fmt.Errorf("get public gift: %w", err)
|
||||
}
|
||||
|
||||
return gift, nil
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -35,14 +36,14 @@ func NewHandler(repository *Repository, objectStorage storage.Storage, processor
|
||||
return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List)))
|
||||
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload)))
|
||||
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload)))
|
||||
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update)))
|
||||
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download)))
|
||||
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
func (h *Handler) RegisterRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/galleries/:id/media", require, h.List)
|
||||
router.POST("/api/galleries/:id/uploads", require, h.CreateUpload)
|
||||
router.POST("/api/uploads/:id/complete", require, h.CompleteUpload)
|
||||
router.PATCH("/api/media/:id", require, h.Update)
|
||||
router.POST("/api/media/:id/download", require, h.Download)
|
||||
router.DELETE("/api/uploads/:id", require, h.Delete)
|
||||
router.DELETE("/api/media/:id", require, h.Delete)
|
||||
}
|
||||
|
||||
type uploadRequest struct {
|
||||
@@ -55,57 +56,82 @@ type updateRequest struct {
|
||||
SortOrder *int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// List godoc
|
||||
// @Summary List gallery media
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/media [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
galleryID, err := parseID(r.PathValue("id"))
|
||||
galleryID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid gallery id")
|
||||
writeError(c, http.StatusBadRequest, "invalid gallery id")
|
||||
return
|
||||
}
|
||||
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
|
||||
belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID)
|
||||
if err != nil || !belongs {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
items, err := h.repository.ListByGallery(r.Context(), galleryID)
|
||||
items, err := h.repository.ListByGallery(c.Request.Context(), galleryID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load media")
|
||||
return
|
||||
}
|
||||
views := make([]Public, 0, len(items))
|
||||
for _, item := range items {
|
||||
view, err := h.view(r.Context(), item, true)
|
||||
view, err := h.view(c.Request.Context(), item, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": views})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": views})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// CreateUpload godoc
|
||||
// @Summary Create a presigned media upload
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Param request body uploadRequest true "Upload metadata"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/uploads [post]
|
||||
func (h *Handler) CreateUpload(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
galleryID, err := parseID(r.PathValue("id"))
|
||||
galleryID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid gallery id")
|
||||
writeError(c, http.StatusBadRequest, "invalid gallery id")
|
||||
return
|
||||
}
|
||||
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
|
||||
belongs, err := h.repository.GalleryBelongsToUser(c.Request.Context(), galleryID, user.ID)
|
||||
if err != nil || !belongs {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
var request uploadRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
filename := safeFilename(request.Filename)
|
||||
@@ -114,145 +140,192 @@ func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
|
||||
mimeType = mime.TypeByExtension(filepath.Ext(filename))
|
||||
}
|
||||
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
|
||||
writeError(w, http.StatusBadRequest, "unsupported media file")
|
||||
writeError(c, http.StatusBadRequest, "unsupported media file")
|
||||
return
|
||||
}
|
||||
if request.FileSize <= 0 || request.FileSize > maxUploadSize {
|
||||
writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
|
||||
writeError(c, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
|
||||
return
|
||||
}
|
||||
mediaID := uuid.New()
|
||||
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename)
|
||||
item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
|
||||
item, err := h.repository.Create(c.Request.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create upload")
|
||||
writeError(c, http.StatusInternalServerError, "could not create upload")
|
||||
return
|
||||
}
|
||||
uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration)
|
||||
uploadURL, err := h.storage.CreateUploadURL(c.Request.Context(), storageKey, mimeType, uploadURLDuration)
|
||||
if err != nil {
|
||||
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID)
|
||||
writeError(w, http.StatusInternalServerError, "could not create upload URL")
|
||||
_, _ = h.repository.Delete(c.Request.Context(), user.ID, mediaID)
|
||||
writeError(c, http.StatusInternalServerError, "could not create upload URL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
writeJSON(c, http.StatusCreated, map[string]any{
|
||||
"uploadId": mediaID.String(),
|
||||
"uploadUrl": uploadURL,
|
||||
"media": publicFromRecord(item),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// CompleteUpload godoc
|
||||
// @Summary Complete a media upload
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/uploads/{id}/complete [post]
|
||||
func (h *Handler) CompleteUpload(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
info, err := h.storage.Stat(r.Context(), item.StorageKey)
|
||||
info, err := h.storage.Stat(c.Request.Context(), item.StorageKey)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "uploaded object is not available yet")
|
||||
writeError(c, http.StatusBadRequest, "uploaded object is not available yet")
|
||||
return
|
||||
}
|
||||
item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size)
|
||||
item, err = h.repository.Complete(c.Request.Context(), user.ID, mediaID, info.Size)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not complete upload")
|
||||
writeError(c, http.StatusInternalServerError, "could not complete upload")
|
||||
return
|
||||
}
|
||||
h.processor.Enqueue(item.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Update godoc
|
||||
// @Summary Update media ordering
|
||||
// @Tags media
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Param request body updateRequest true "Media changes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/media/{id} [patch]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
var request updateRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if request.SortOrder == nil || *request.SortOrder < 0 {
|
||||
writeError(w, http.StatusBadRequest, "sort order must be zero or greater")
|
||||
writeError(c, http.StatusBadRequest, "sort order must be zero or greater")
|
||||
return
|
||||
}
|
||||
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
if err := h.repository.UpdateSortOrder(c.Request.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
view, err := h.view(r.Context(), item, true)
|
||||
item, _ := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
view, err := h.view(c.Request.Context(), item, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URL")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URL")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"media": view})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"media": view})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Delete godoc
|
||||
// @Summary Delete media or cancel an upload
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 204
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/uploads/{id} [delete]
|
||||
// @Router /api/media/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.Delete(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.Delete(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
|
||||
if key != "" && key != item.ExternalURL {
|
||||
_ = h.storage.Delete(r.Context(), key)
|
||||
_ = h.storage.Delete(c.Request.Context(), key)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Download godoc
|
||||
// @Summary Create a signed original media download URL
|
||||
// @Tags media
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/media/{id}/download [post]
|
||||
func (h *Handler) Download(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
mediaID, err := parseID(r.PathValue("id"))
|
||||
mediaID, err := parseID(c.Param("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
|
||||
item, err := h.repository.GetForUser(c.Request.Context(), user.ID, mediaID)
|
||||
if err != nil || item.ProcessingStatus != StatusReady {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
url := item.ExternalURL
|
||||
if url == "" {
|
||||
url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
|
||||
url, err = h.storage.CreateDownloadURL(c.Request.Context(), item.StorageKey, time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create download")
|
||||
writeError(c, http.StatusInternalServerError, "could not create download")
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"url": url})
|
||||
_ = h.repository.RecordDownload(c.Request.Context(), item.GalleryID, &item.ID, user.ID.String())
|
||||
writeJSON(c, http.StatusOK, map[string]string{"url": url})
|
||||
}
|
||||
|
||||
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) {
|
||||
@@ -330,26 +403,24 @@ func parseID(value string) (uuid.UUID, error) {
|
||||
return uuid.Parse(value)
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *MinIO) EnsureBucket(ctx context.Context) error {
|
||||
origins = []string{"*"}
|
||||
}
|
||||
if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{
|
||||
ID: "northline-browser-uploads",
|
||||
ID: "studio-browser-uploads",
|
||||
AllowedOrigin: origins,
|
||||
AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"},
|
||||
AllowedHeader: []string{"*"},
|
||||
|
||||
Reference in New Issue
Block a user