init
This commit is contained in:
@@ -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 = ¤t.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 ©
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
Reference in New Issue
Block a user