802 lines
24 KiB
Go
802 lines
24 KiB
Go
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/gin-gonic/gin"
|
|
"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(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(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 {
|
|
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"`
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID)
|
|
if err != nil {
|
|
writeError(c, 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(c.Request.Context(), user.ID, coverID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false)
|
|
}
|
|
writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries})
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
var request createRequest
|
|
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(c, http.StatusBadRequest, "title and client name are required")
|
|
return
|
|
}
|
|
|
|
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(c, http.StatusInternalServerError, "could not create gallery")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
record, err := h.recordForUser(c, user.ID)
|
|
if err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
|
return
|
|
}
|
|
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
record, err := h.recordForUser(c, user.ID)
|
|
if err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "")
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not build gallery preview")
|
|
return
|
|
}
|
|
gallery.Preview = true
|
|
writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery})
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
current, err := h.recordForUser(c, user.ID)
|
|
if err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
var request updateRequest
|
|
if !decodeJSON(c, &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(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(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(c, 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(c, 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(c, http.StatusBadRequest, "invalid cover media id")
|
|
return
|
|
}
|
|
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
|
if err != nil || cover.GalleryID != current.ID {
|
|
writeError(c, 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(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(c, http.StatusBadRequest, "branding config must be valid JSON")
|
|
return
|
|
}
|
|
input.BrandingConfig = request.BrandingConfig
|
|
}
|
|
|
|
record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not update gallery")
|
|
return
|
|
}
|
|
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
|
return
|
|
}
|
|
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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(c *gin.Context, status string) {
|
|
user, ok := auth.UserFromContext(c)
|
|
if !ok {
|
|
writeError(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
record, err := h.recordForUser(c, user.ID)
|
|
if err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not update gallery status")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
|
}
|
|
|
|
// 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(c, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
id, err := pathUUID(c, "id")
|
|
if err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil {
|
|
writeGalleryError(c, err)
|
|
return
|
|
}
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// 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(c, http.StatusNotFound, "gallery not found")
|
|
return
|
|
}
|
|
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
|
gallery := h.lockedPayload(record)
|
|
writeJSON(c, http.StatusOK, gallery)
|
|
return
|
|
}
|
|
visitorID := ""
|
|
if record.FavoritesEnabled {
|
|
visitorID = h.auth.EnsureVisitor(c)
|
|
}
|
|
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusOK, gallery)
|
|
}
|
|
|
|
// 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(c, http.StatusNotFound, "gallery not found")
|
|
return
|
|
}
|
|
var request publicPasswordRequest
|
|
if !decodeJSON(c, &request) {
|
|
return
|
|
}
|
|
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
|
|
writeError(c, http.StatusUnauthorized, "incorrect gallery password")
|
|
return
|
|
}
|
|
h.auth.GrantGalleryAccess(c, record.Slug)
|
|
visitorID := ""
|
|
if record.FavoritesEnabled {
|
|
visitorID = h.auth.EnsureVisitor(c)
|
|
}
|
|
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
|
if err != nil {
|
|
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
|
return
|
|
}
|
|
writeJSON(c, http.StatusOK, gallery)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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(c *gin.Context, favorited bool) {
|
|
record, err := h.publicRecordForRequest(c)
|
|
if err != nil {
|
|
writeError(c, http.StatusNotFound, "gallery not found")
|
|
return
|
|
}
|
|
if !record.FavoritesEnabled {
|
|
writeError(c, http.StatusForbidden, "favorites are disabled")
|
|
return
|
|
}
|
|
mediaID, err := pathUUID(c, "mediaId")
|
|
if err != nil {
|
|
writeError(c, http.StatusBadRequest, "invalid media id")
|
|
return
|
|
}
|
|
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
|
if err != nil || item.GalleryID != record.ID {
|
|
writeError(c, http.StatusNotFound, "media not found")
|
|
return
|
|
}
|
|
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(c, http.StatusOK, map[string]bool{"favorited": favorited})
|
|
}
|
|
|
|
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(c, record.Slug) {
|
|
return GalleryRecord{}, ErrNotFound
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
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(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) {
|
|
id, err := pathUUID(c, "id")
|
|
if err != nil {
|
|
return GalleryRecord{}, err
|
|
}
|
|
return h.repository.GetForUser(c.Request.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(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)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func stringPointer(value string) *string {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
copy := value
|
|
return ©
|
|
}
|
|
|
|
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(c.Writer, c.Request.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeGalleryError(c *gin.Context, err error) {
|
|
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
|
|
writeError(c, http.StatusNotFound, "gallery not found")
|
|
return
|
|
}
|
|
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
|
}
|
|
|
|
func writeError(c *gin.Context, status int, message string) {
|
|
writeJSON(c, status, map[string]string{"error": message})
|
|
}
|
|
|
|
func writeJSON(c *gin.Context, status int, value any) {
|
|
c.JSON(status, value)
|
|
}
|