This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+677
View File
@@ -0,0 +1,677 @@
package galleries
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const signedURLDuration = time.Hour
type Handler struct {
repository *Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
}
func NewHandler(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service) *Handler {
return &Handler{
repository: repository,
media: mediaRepository,
storage: objectStorage,
auth: authService,
}
}
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) 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)
}
type createRequest struct {
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
}
type updateRequest struct {
Title *string `json:"title"`
ClientName *string `json:"clientName"`
Description *string `json:"description"`
Password *string `json:"password"`
ClearPassword bool `json:"clearPassword"`
DownloadsEnabled *bool `json:"downloadsEnabled"`
FavoritesEnabled *bool `json:"favoritesEnabled"`
DownloadAllEnabled *bool `json:"downloadAllEnabled"`
WatermarkEnabled *bool `json:"watermarkEnabled"`
ExpiresAt *string `json:"expiresAt"`
CoverMediaID *string `json:"coverMediaId"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
}
type publicPasswordRequest struct {
Password string `json:"password"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load galleries")
return
}
for index := range summaries {
if summaries[index].CoverMediaID == "" {
continue
}
coverID, err := uuid.Parse(summaries[index].CoverMediaID)
if err != nil {
continue
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil {
continue
}
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
var request createRequest
if !decodeJSON(w, r, &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")
return
}
record, err := h.repository.Create(r.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")
return
}
writeJSON(w, 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())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, 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())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
return
}
gallery.Preview = true
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
current, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
input := UpdateInput{
Title: current.Title,
ClientName: current.ClientName,
Description: current.Description,
DownloadsEnabled: current.DownloadsEnabled,
FavoritesEnabled: current.FavoritesEnabled,
DownloadAllEnabled: current.DownloadAllEnabled,
WatermarkEnabled: current.WatermarkEnabled,
ExpiresAt: stringPointer(current.ExpiresAt),
CoverMediaID: stringPointer(current.CoverMediaID),
ThemeConfig: current.ThemeConfig,
BrandingConfig: current.BrandingConfig,
}
if current.PasswordHash != "" {
input.PasswordHash = &current.PasswordHash
}
if request.Title != nil {
input.Title = strings.TrimSpace(*request.Title)
}
if request.ClientName != nil {
input.ClientName = strings.TrimSpace(*request.ClientName)
}
if request.Description != nil {
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")
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")
return
} else {
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
return
}
hashed := string(hash)
input.PasswordHash = &hashed
input.ClearPassword = false
}
}
if request.ClearPassword {
input.ClearPassword = true
input.PasswordHash = nil
}
if request.DownloadsEnabled != nil {
input.DownloadsEnabled = *request.DownloadsEnabled
}
if request.FavoritesEnabled != nil {
input.FavoritesEnabled = *request.FavoritesEnabled
}
if request.DownloadAllEnabled != nil {
input.DownloadAllEnabled = *request.DownloadAllEnabled
}
if request.WatermarkEnabled != nil {
input.WatermarkEnabled = *request.WatermarkEnabled
}
if request.ExpiresAt != nil {
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")
return
}
}
input.ExpiresAt = &value
}
if request.CoverMediaID != nil {
value := strings.TrimSpace(*request.CoverMediaID)
if value != "" {
coverID, err := uuid.Parse(value)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cover media id")
return
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil || cover.GalleryID != current.ID {
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
return
}
}
input.CoverMediaID = &value
}
if len(request.ThemeConfig) > 0 {
if !json.Valid(request.ThemeConfig) {
writeError(w, 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")
return
}
input.BrandingConfig = request.BrandingConfig
}
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery")
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, 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)
}
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, StatusDraft)
}
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery status")
return
}
writeJSON(w, 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())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
id, err := pathUUID(r, "id")
if err != nil {
writeGalleryError(w, err)
return
}
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
writeGalleryError(w, err)
return
}
w.WriteHeader(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)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
gallery := h.lockedPayload(record)
writeJSON(w, http.StatusOK, gallery)
return
}
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, 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)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request publicPasswordRequest
if !decodeJSON(w, r, &request) {
return
}
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
return
}
h.auth.GrantGalleryAccess(w, record.Slug)
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, http.StatusOK, gallery)
}
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, true)
}
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, false)
}
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
record, err := h.publicRecordForRequest(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.FavoritesEnabled {
writeError(w, http.StatusForbidden, "favorites are disabled")
return
}
mediaID, err := pathUUID(r, "mediaId")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID {
writeError(w, 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")
return
}
writeJSON(w, 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")))
if err != nil || record.IsExpired() {
return GalleryRecord{}, ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, 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) {
items, err := h.media.ListByGallery(ctx, record.ID)
if err != nil {
return Public{}, err
}
views, err := h.mediaViews(ctx, items, visitorID, includeOriginal)
if err != nil {
return Public{}, err
}
gallery := Public{
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
Media: views,
}
for index := range items {
if record.CoverMediaID != "" && items[index].ID.String() == record.CoverMediaID {
gallery.Cover = &views[index]
break
}
}
if gallery.Cover == nil {
for index := range items {
if media.IsImage(items[index]) && views[index].PreviewURL != "" {
gallery.Cover = &views[index]
break
}
}
}
return gallery, nil
}
func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
return map[string]any{
"slug": record.Slug,
"title": record.Title,
"clientName": record.ClientName,
"description": record.Description,
"themeConfig": record.ThemeConfig,
"brandingConfig": record.BrandingConfig,
"requiresPassword": true,
"media": []media.Public{},
}
}
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
id, err := pathUUID(r, "id")
if err != nil {
return GalleryRecord{}, err
}
return h.repository.GetForUser(r.Context(), userID, id)
}
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
if items == nil {
items = []media.Public{}
}
return Detail{
ID: record.ID.String(),
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
Status: record.Status,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
CoverMediaID: record.CoverMediaID,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
CreatedAt: record.CreatedAt,
PublishedAt: record.PublishedAt,
Media: items,
}
}
func (h *Handler) mediaViews(ctx context.Context, items []media.Record, visitorID string, includeOriginal bool) ([]media.Public, error) {
views := make([]media.Public, 0, len(items))
for _, item := range items {
view := media.Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
if item.ProcessingStatus == media.StatusReady {
var err error
view.ThumbnailURL, err = h.mediaVariantURL(ctx, item, item.ThumbnailKey)
if err != nil {
return nil, err
}
view.PreviewURL, err = h.mediaURL(ctx, item, false)
if err != nil {
return nil, err
}
if includeOriginal {
view.OriginalURL, err = h.mediaURL(ctx, item, true)
if err != nil {
return nil, err
}
}
}
if visitorID != "" {
var err error
view.Favorited, err = h.media.IsFavorited(ctx, item.GalleryID, item.ID, visitorID)
if err != nil {
return nil, err
}
}
views = append(views, view)
}
return views, nil
}
func (h *Handler) mediaURL(ctx context.Context, item media.Record, original bool) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
key := item.PreviewKey
if original {
key = item.StorageKey
}
return h.mediaVariantURL(ctx, item, key)
}
func (h *Handler) mediaVariantURL(ctx context.Context, item media.Record, key string) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
if key == "" {
return "", fmt.Errorf("media object is not ready")
}
return h.storage.CreateDownloadURL(ctx, key, signedURLDuration)
}
func newSlug(title string) string {
var builder strings.Builder
lastWasSeparator := true
for _, character := range strings.ToLower(title) {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
builder.WriteRune(character)
lastWasSeparator = false
continue
}
if builder.Len() > 0 && !lastWasSeparator {
builder.WriteByte('-')
lastWasSeparator = true
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
slug = "gallery"
}
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
return slug + "-" + suffix
}
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
id, err := uuid.Parse(r.PathValue(name))
if err != nil {
return uuid.Nil, fmt.Errorf("invalid %s", name)
}
return id, nil
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
copy := value
return &copy
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeGalleryError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gallery")
}
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)
}
@@ -0,0 +1,90 @@
package galleries
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/example/sndit/backend/internal/auth"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/example/sndit/backend/internal/media"
"github.com/google/uuid"
)
func TestPublicGalleryAndFavoritesOnSQLite(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")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
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 {
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 {
t.Fatalf("insert gallery: %v", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, storage_key, external_url, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, galleryID, "one.jpg", "image/jpeg", "demo/one.jpg", "https://example.com/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
authService, err := auth.NewService(auth.NewRepository(database), "test-gallery-secret-that-is-long-enough", false)
if err != nil {
t.Fatalf("create auth service: %v", err)
}
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
mux := http.NewServeMux()
handler.RegisterPublicRoutes(mux)
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
getRecorder := httptest.NewRecorder()
mux.ServeHTTP(getRecorder, getRequest)
if getRecorder.Code != http.StatusOK {
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
}
var gallery Public
if err := json.NewDecoder(getRecorder.Body).Decode(&gallery); err != nil {
t.Fatalf("decode public gallery: %v", err)
}
if gallery.Title != "Emma & James" || len(gallery.Media) != 1 || gallery.Media[0].PreviewURL != "https://example.com/one.jpg" {
t.Fatalf("unexpected public gallery: %+v", gallery)
}
favoriteRequest := httptest.NewRequest(http.MethodPost, "/api/public/galleries/demo-gallery/media/77777777-7777-4777-8777-777777777777/favorite", nil)
for _, cookie := range getRecorder.Result().Cookies() {
favoriteRequest.AddCookie(cookie)
}
favoriteRecorder := httptest.NewRecorder()
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
if favoriteRecorder.Code != http.StatusOK {
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
}
}
+125
View File
@@ -0,0 +1,125 @@
package galleries
import (
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
)
const (
StatusDraft = "draft"
StatusPublished = "published"
StatusArchived = "archived"
)
type GalleryRecord struct {
ID uuid.UUID
UserID uuid.UUID
Slug string
Title string
ClientName string
Description string
Status string
PasswordHash string
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt string
CoverMediaID string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
CreatedAt string
UpdatedAt string
PublishedAt string
}
type Summary struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
PhotoCount int `json:"photoCount"`
VideoCount int `json:"videoCount"`
TotalBytes int64 `json:"totalBytes"`
}
type UpdateInput struct {
Title string
ClientName string
Description string
PasswordHash *string
ClearPassword bool
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt *string
CoverMediaID *string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
}
func (g GalleryRecord) IsExpired() bool {
if strings.TrimSpace(g.ExpiresAt) == "" {
return false
}
value := strings.TrimSpace(g.ExpiresAt)
layouts := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05-07",
"2006-01-02 15:04:05",
}
var parsed time.Time
var err error
for _, layout := range layouts {
parsed, err = time.Parse(layout, value)
if err == nil {
break
}
}
if err != nil {
return false
}
return !time.Now().Before(parsed)
}
func (g GalleryRecord) ToSummary(photoCount, videoCount int, totalBytes int64) Summary {
return Summary{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
ClientName: g.ClientName,
Description: g.Description,
Status: g.Status,
DownloadsEnabled: g.DownloadsEnabled,
FavoritesEnabled: g.FavoritesEnabled,
DownloadAllEnabled: g.DownloadAllEnabled,
WatermarkEnabled: g.WatermarkEnabled,
ExpiresAt: g.ExpiresAt,
CoverMediaID: g.CoverMediaID,
ThemeConfig: g.ThemeConfig,
BrandingConfig: g.BrandingConfig,
CreatedAt: g.CreatedAt,
PublishedAt: g.PublishedAt,
PhotoCount: photoCount,
VideoCount: videoCount,
TotalBytes: totalBytes,
}
}
+230
View File
@@ -0,0 +1,230 @@
package galleries
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("gallery not found")
func (r *Repository) Create(ctx context.Context, userID uuid.UUID, slug, title, clientName, description string) (GalleryRecord, error) {
id := uuid.New()
_, err := r.db.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, theme_config, branding_config)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', '{}', '{}')
`, id, userID, slug, title, clientName, description)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return GalleryRecord{}, fmt.Errorf("gallery slug already exists: %w", err)
}
return GalleryRecord{}, fmt.Errorf("create gallery: %w", err)
}
return r.GetForUser(ctx, userID, id)
}
func (r *Repository) ListForUser(ctx context.Context, userID uuid.UUID) ([]Summary, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT
g.id, g.slug, g.title, g.client_name, g.description, g.status,
g.downloads_enabled, g.favorites_enabled, g.download_all_enabled,
g.watermark_enabled, g.expires_at, g.cover_media_id,
g.theme_config, g.branding_config, g.created_at, g.published_at,
COUNT(CASE WHEN m.mime_type LIKE 'image/%' THEN 1 END),
COUNT(CASE WHEN m.mime_type LIKE 'video/%' THEN 1 END),
COALESCE(SUM(m.file_size), 0)
FROM galleries g
LEFT JOIN media m ON m.gallery_id = g.id
WHERE g.user_id = $1 AND g.status <> 'archived'
GROUP BY g.id
ORDER BY g.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list galleries: %w", err)
}
defer rows.Close()
result := make([]Summary, 0)
for rows.Next() {
var (
item Summary
expiresAt, coverMediaID, createdAt sql.NullString
publishedAt sql.NullString
themeConfig, brandingConfig []byte
)
if err := rows.Scan(
&item.ID, &item.Slug, &item.Title, &item.ClientName, &item.Description, &item.Status,
&item.DownloadsEnabled, &item.FavoritesEnabled, &item.DownloadAllEnabled,
&item.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig,
&createdAt, &publishedAt, &item.PhotoCount, &item.VideoCount, &item.TotalBytes,
); err != nil {
return nil, fmt.Errorf("scan gallery summary: %w", err)
}
item.ExpiresAt = expiresAt.String
item.CoverMediaID = coverMediaID.String
item.ThemeConfig = nonEmptyJSON(themeConfig)
item.BrandingConfig = nonEmptyJSON(brandingConfig)
item.CreatedAt = createdAt.String
item.PublishedAt = publishedAt.String
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate galleries: %w", err)
}
return result, nil
}
func (r *Repository) GetForUser(ctx context.Context, userID, galleryID uuid.UUID) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE id = $1 AND user_id = $2
`, galleryID, userID)
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE slug = $1 AND status = 'published'
`, slug)
}
func (r *Repository) Update(ctx context.Context, userID, galleryID uuid.UUID, input UpdateInput) (GalleryRecord, error) {
var passwordValue any
if input.ClearPassword {
passwordValue = nil
} else if input.PasswordHash != nil {
passwordValue = *input.PasswordHash
} else {
current, err := r.GetForUser(ctx, userID, galleryID)
if err != nil {
return GalleryRecord{}, err
}
passwordValue = current.PasswordHash
}
var expiresValue any
if input.ExpiresAt != nil && strings.TrimSpace(*input.ExpiresAt) != "" {
expiresValue = *input.ExpiresAt
}
var coverValue any
if input.CoverMediaID != nil && strings.TrimSpace(*input.CoverMediaID) != "" {
coverID, err := uuid.Parse(strings.TrimSpace(*input.CoverMediaID))
if err != nil {
return GalleryRecord{}, fmt.Errorf("parse cover media id: %w", err)
}
coverValue = coverID
}
_, err := r.db.ExecContext(ctx, `
UPDATE galleries
SET title = $1, client_name = $2, description = $3,
password_hash = $4, downloads_enabled = $5, favorites_enabled = $6,
download_all_enabled = $7, watermark_enabled = $8, expires_at = $9,
cover_media_id = $10, theme_config = $11, branding_config = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 AND user_id = $14
`, input.Title, input.ClientName, input.Description, passwordValue, input.DownloadsEnabled,
input.FavoritesEnabled, input.DownloadAllEnabled, input.WatermarkEnabled, expiresValue,
coverValue, jsonValue(input.ThemeConfig), jsonValue(input.BrandingConfig), galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("update gallery: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) SetStatus(ctx context.Context, userID, galleryID uuid.UUID, status string) (GalleryRecord, error) {
var query string
if status == StatusPublished {
query = `UPDATE galleries SET status = $1, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
} else {
query = `UPDATE galleries SET status = $1, published_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
}
_, err := r.db.ExecContext(ctx, query, status, galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("set gallery status: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) Delete(ctx context.Context, userID, galleryID uuid.UUID) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM galleries WHERE id = $1 AND user_id = $2`, galleryID, userID)
if err != nil {
return fmt.Errorf("delete gallery: %w", err)
}
count, err := result.RowsAffected()
if err != nil || count == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (GalleryRecord, error) {
return scanGallery(r.db.QueryRowContext(ctx, query, args...))
}
func scanGallery(row rowScanner) (GalleryRecord, error) {
var (
gallery GalleryRecord
passwordHash, expiresAt, coverMediaID sql.NullString
themeConfig, brandingConfig []byte
createdAt, updatedAt, publishedAt sql.NullString
)
err := row.Scan(
&gallery.ID, &gallery.UserID, &gallery.Slug, &gallery.Title, &gallery.ClientName,
&gallery.Description, &gallery.Status, &passwordHash, &gallery.DownloadsEnabled,
&gallery.FavoritesEnabled, &gallery.DownloadAllEnabled, &gallery.WatermarkEnabled,
&expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &updatedAt, &publishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return GalleryRecord{}, ErrNotFound
}
if err != nil {
return GalleryRecord{}, fmt.Errorf("scan gallery: %w", err)
}
gallery.PasswordHash = passwordHash.String
gallery.ExpiresAt = expiresAt.String
gallery.CoverMediaID = coverMediaID.String
gallery.ThemeConfig = nonEmptyJSON(themeConfig)
gallery.BrandingConfig = nonEmptyJSON(brandingConfig)
gallery.CreatedAt = createdAt.String
gallery.UpdatedAt = updatedAt.String
gallery.PublishedAt = publishedAt.String
return gallery, nil
}
func nonEmptyJSON(value []byte) json.RawMessage {
if len(value) == 0 {
return json.RawMessage(`{}`)
}
return json.RawMessage(value)
}
func jsonValue(value json.RawMessage) string {
if len(value) == 0 {
return `{}`
}
return string(value)
}
@@ -0,0 +1,87 @@
package galleries
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/google/uuid"
)
func TestRepositoryHandlesSQLiteGallerySchema(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")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
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 {
t.Fatalf("insert user: %v", err)
}
repository := NewRepository(database)
record, err := repository.Create(ctx, userID, "emma-james-wedding-12345678", "Emma & James", "Emma & James", "A summer wedding.")
if err != nil {
t.Fatalf("create gallery: %v", err)
}
if record.Status != StatusDraft || record.Title != "Emma & James" {
t.Fatalf("unexpected gallery: %+v", record)
}
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, record.ID, "one.jpg", "image/jpeg", 4096, "gallery/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
updated, err := repository.Update(ctx, userID, record.ID, UpdateInput{
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
DownloadsEnabled: true,
FavoritesEnabled: true,
DownloadAllEnabled: true,
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
})
if err != nil {
t.Fatalf("update gallery: %v", err)
}
if string(updated.ThemeConfig) != `{"mode":"dark"}` {
t.Fatalf("theme config was not persisted: %s", updated.ThemeConfig)
}
summaries, err := repository.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list galleries: %v", err)
}
if len(summaries) != 1 || summaries[0].PhotoCount != 1 || summaries[0].TotalBytes != 4096 {
t.Fatalf("unexpected summary: %+v", summaries)
}
if _, err := repository.SetStatus(ctx, userID, record.ID, StatusPublished); err != nil {
t.Fatalf("publish gallery: %v", err)
}
if _, err := repository.GetPublicBySlug(ctx, record.Slug); err != nil {
t.Fatalf("public gallery lookup: %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package galleries
import (
"encoding/json"
"github.com/example/sndit/backend/internal/media"
)
type Detail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
Media []media.Public `json:"media"`
}
type Public struct {
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
Preview bool `json:"preview,omitempty"`
RequiresPassword bool `json:"requiresPassword"`
Cover *media.Public `json:"cover,omitempty"`
Media []media.Public `json:"media"`
}