ai slop ah
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/example/sndit/backend/internal/storage"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -34,22 +35,22 @@ func NewHandler(repository *Repository, mediaRepository *media.Repository, objec
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
|
||||
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List)))
|
||||
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create)))
|
||||
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get)))
|
||||
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update)))
|
||||
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete)))
|
||||
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish)))
|
||||
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish)))
|
||||
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview)))
|
||||
func (h *Handler) RegisterProtectedRoutes(router gin.IRouter, require gin.HandlerFunc) {
|
||||
router.GET("/api/galleries", require, h.List)
|
||||
router.POST("/api/galleries", require, h.Create)
|
||||
router.GET("/api/galleries/:id", require, h.Get)
|
||||
router.PATCH("/api/galleries/:id", require, h.Update)
|
||||
router.DELETE("/api/galleries/:id", require, h.Delete)
|
||||
router.POST("/api/galleries/:id/publish", require, h.Publish)
|
||||
router.POST("/api/galleries/:id/unpublish", require, h.Unpublish)
|
||||
router.GET("/api/galleries/:id/preview", require, h.Preview)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic)
|
||||
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite)
|
||||
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite)
|
||||
func (h *Handler) RegisterPublicRoutes(router gin.IRouter) {
|
||||
router.GET("/api/public/galleries/:slug", h.Public)
|
||||
router.POST("/api/public/galleries/:slug/authenticate", h.AuthenticatePublic)
|
||||
router.POST("/api/public/galleries/:slug/media/:mediaId/favorite", h.Favorite)
|
||||
router.DELETE("/api/public/galleries/:slug/media/:mediaId/favorite", h.Unfavorite)
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
@@ -78,15 +79,23 @@ type publicPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// List godoc
|
||||
// @Summary List owned galleries
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
|
||||
summaries, err := h.repository.ListForUser(c.Request.Context(), user.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load galleries")
|
||||
writeError(c, http.StatusInternalServerError, "could not load galleries")
|
||||
return
|
||||
}
|
||||
for index := range summaries {
|
||||
@@ -97,97 +106,141 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
|
||||
summaries[index].CoverURL, _ = h.mediaURL(c.Request.Context(), cover, false)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"galleries": summaries})
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Create godoc
|
||||
// @Summary Create a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param request body createRequest true "Gallery details"
|
||||
// @Success 201 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /api/galleries [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
var request createRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
request.Title = strings.TrimSpace(request.Title)
|
||||
request.ClientName = strings.TrimSpace(request.ClientName)
|
||||
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
record, err := h.repository.Create(c.Request.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not create gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Get godoc
|
||||
// @Summary Get an owned gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [get]
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Preview godoc
|
||||
// @Summary Preview a gallery as a client
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/preview [get]
|
||||
func (h *Handler) Preview(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, true, true, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
|
||||
writeError(c, http.StatusInternalServerError, "could not build gallery preview")
|
||||
return
|
||||
}
|
||||
gallery.Preview = true
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": gallery})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Update godoc
|
||||
// @Summary Update a gallery
|
||||
// @Tags galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Param request body map[string]interface{} true "Gallery changes"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [patch]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
current, err := h.recordForUser(r, user.ID)
|
||||
current, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
var request updateRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -217,19 +270,19 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
input.Description = strings.TrimSpace(*request.Description)
|
||||
}
|
||||
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 {
|
||||
writeError(w, http.StatusBadRequest, "title and client name are required")
|
||||
writeError(c, http.StatusBadRequest, "title and client name are required")
|
||||
return
|
||||
}
|
||||
if request.Password != nil {
|
||||
if strings.TrimSpace(*request.Password) == "" {
|
||||
input.ClearPassword = true
|
||||
} else if len(*request.Password) < 4 {
|
||||
writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
writeError(c, http.StatusBadRequest, "gallery password must be at least 4 characters")
|
||||
return
|
||||
} else {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
|
||||
writeError(c, http.StatusInternalServerError, "could not secure gallery password")
|
||||
return
|
||||
}
|
||||
hashed := string(hash)
|
||||
@@ -257,7 +310,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
value := strings.TrimSpace(*request.ExpiresAt)
|
||||
if value != "" {
|
||||
if _, err := time.Parse(time.RFC3339, value); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
writeError(c, http.StatusBadRequest, "expiry must be an ISO timestamp")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -268,12 +321,12 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
if value != "" {
|
||||
coverID, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid cover media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid cover media id")
|
||||
return
|
||||
}
|
||||
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
|
||||
cover, err := h.media.GetForUser(c.Request.Context(), user.ID, coverID)
|
||||
if err != nil || cover.GalleryID != current.ID {
|
||||
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
writeError(c, http.StatusBadRequest, "cover media does not belong to this gallery")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -281,182 +334,255 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(request.ThemeConfig) > 0 {
|
||||
if !json.Valid(request.ThemeConfig) {
|
||||
writeError(w, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "theme config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.ThemeConfig = request.ThemeConfig
|
||||
}
|
||||
if len(request.BrandingConfig) > 0 {
|
||||
if !json.Valid(request.BrandingConfig) {
|
||||
writeError(w, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
writeError(c, http.StatusBadRequest, "branding config must be valid JSON")
|
||||
return
|
||||
}
|
||||
input.BrandingConfig = request.BrandingConfig
|
||||
}
|
||||
|
||||
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
|
||||
record, err := h.repository.Update(c.Request.Context(), user.ID, current.ID, input)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery")
|
||||
return
|
||||
}
|
||||
items, err := h.media.ListByGallery(r.Context(), record.ID)
|
||||
items, err := h.media.ListByGallery(c.Request.Context(), record.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery media")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery media")
|
||||
return
|
||||
}
|
||||
views, err := h.mediaViews(r.Context(), items, "", true)
|
||||
views, err := h.mediaViews(c.Request.Context(), items, "", true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
|
||||
writeError(c, http.StatusInternalServerError, "could not sign media URLs")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
|
||||
}
|
||||
|
||||
func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusPublished)
|
||||
// Publish godoc
|
||||
// @Summary Publish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/publish [post]
|
||||
func (h *Handler) Publish(c *gin.Context) {
|
||||
h.setStatus(c, StatusPublished)
|
||||
}
|
||||
|
||||
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setStatus(w, r, StatusDraft)
|
||||
// Unpublish godoc
|
||||
// @Summary Unpublish a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id}/unpublish [post]
|
||||
func (h *Handler) Unpublish(c *gin.Context) {
|
||||
h.setStatus(c, StatusDraft)
|
||||
}
|
||||
|
||||
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
func (h *Handler) setStatus(c *gin.Context, status string) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
record, err := h.recordForUser(r, user.ID)
|
||||
record, err := h.recordForUser(c, user.ID)
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
|
||||
record, err = h.repository.SetStatus(c.Request.Context(), user.ID, record.ID, status)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update gallery status")
|
||||
writeError(c, http.StatusInternalServerError, "could not update gallery status")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
writeJSON(c, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
// Delete godoc
|
||||
// @Summary Delete a gallery
|
||||
// @Tags galleries
|
||||
// @Produce json
|
||||
// @Security studioSession
|
||||
// @Param id path string true "Gallery UUID"
|
||||
// @Success 204
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/galleries/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
user, ok := auth.UserFromContext(c)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
writeError(c, http.StatusUnauthorized, "authentication required")
|
||||
return
|
||||
}
|
||||
id, err := pathUUID(r, "id")
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
writeGalleryError(w, err)
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(w, err)
|
||||
if err := h.repository.Delete(c.Request.Context(), user.ID, id); err != nil {
|
||||
writeGalleryError(c, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// Public godoc
|
||||
// @Summary Get a published public gallery
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug} [get]
|
||||
func (h *Handler) Public(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
gallery := h.lockedPayload(record)
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
return
|
||||
}
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) {
|
||||
slug := strings.TrimSpace(r.PathValue("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
|
||||
// AuthenticatePublic godoc
|
||||
// @Summary Authenticate to a password-protected gallery
|
||||
// @Tags public galleries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param request body publicPasswordRequest true "Gallery password"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/authenticate [post]
|
||||
func (h *Handler) AuthenticatePublic(c *gin.Context) {
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), slug)
|
||||
if err != nil || record.IsExpired() {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
var request publicPasswordRequest
|
||||
if !decodeJSON(w, r, &request) {
|
||||
if !decodeJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
|
||||
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
|
||||
writeError(c, http.StatusUnauthorized, "incorrect gallery password")
|
||||
return
|
||||
}
|
||||
h.auth.GrantGalleryAccess(w, record.Slug)
|
||||
h.auth.GrantGalleryAccess(c, record.Slug)
|
||||
visitorID := ""
|
||||
if record.FavoritesEnabled {
|
||||
visitorID = h.auth.EnsureVisitor(w, r)
|
||||
visitorID = h.auth.EnsureVisitor(c)
|
||||
}
|
||||
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
|
||||
gallery, err := h.publicPayload(c.Request.Context(), record, false, false, visitorID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, gallery)
|
||||
writeJSON(c, http.StatusOK, gallery)
|
||||
}
|
||||
|
||||
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, true)
|
||||
// Favorite godoc
|
||||
// @Summary Favorite a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [post]
|
||||
func (h *Handler) Favorite(c *gin.Context) {
|
||||
h.setFavorite(c, true)
|
||||
}
|
||||
|
||||
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
|
||||
h.setFavorite(w, r, false)
|
||||
// Unfavorite godoc
|
||||
// @Summary Remove a favorite from a gallery media item
|
||||
// @Tags public galleries
|
||||
// @Produce json
|
||||
// @Param slug path string true "Gallery slug"
|
||||
// @Param mediaId path string true "Media UUID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 403 {object} map[string]string
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /api/public/galleries/{slug}/media/{mediaId}/favorite [delete]
|
||||
func (h *Handler) Unfavorite(c *gin.Context) {
|
||||
h.setFavorite(c, false)
|
||||
}
|
||||
|
||||
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(r)
|
||||
func (h *Handler) setFavorite(c *gin.Context, favorited bool) {
|
||||
record, err := h.publicRecordForRequest(c)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
if !record.FavoritesEnabled {
|
||||
writeError(w, http.StatusForbidden, "favorites are disabled")
|
||||
writeError(c, http.StatusForbidden, "favorites are disabled")
|
||||
return
|
||||
}
|
||||
mediaID, err := pathUUID(r, "mediaId")
|
||||
mediaID, err := pathUUID(c, "mediaId")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid media id")
|
||||
writeError(c, http.StatusBadRequest, "invalid media id")
|
||||
return
|
||||
}
|
||||
item, err := h.media.GetByID(r.Context(), mediaID)
|
||||
item, err := h.media.GetByID(c.Request.Context(), mediaID)
|
||||
if err != nil || item.GalleryID != record.ID {
|
||||
writeError(w, http.StatusNotFound, "media not found")
|
||||
writeError(c, http.StatusNotFound, "media not found")
|
||||
return
|
||||
}
|
||||
visitorID := h.auth.EnsureVisitor(w, r)
|
||||
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not update favorite")
|
||||
visitorID := h.auth.EnsureVisitor(c)
|
||||
if err := h.media.SetFavorite(c.Request.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
|
||||
writeError(c, http.StatusInternalServerError, "could not update favorite")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
writeJSON(c, http.StatusOK, map[string]bool{"favorited": favorited})
|
||||
}
|
||||
|
||||
func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
|
||||
func (h *Handler) publicRecordForRequest(c *gin.Context) (GalleryRecord, error) {
|
||||
record, err := h.repository.GetPublicBySlug(c.Request.Context(), strings.TrimSpace(c.Param("slug")))
|
||||
if err != nil || record.IsExpired() {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
|
||||
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(c, record.Slug) {
|
||||
return GalleryRecord{}, ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
func (h *Handler) publicPayload(ctx context.Context, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
|
||||
items, err := h.media.ListByGallery(ctx, record.ID)
|
||||
if err != nil {
|
||||
return Public{}, err
|
||||
@@ -509,12 +635,12 @@ func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(r, "id")
|
||||
func (h *Handler) recordForUser(c *gin.Context, userID uuid.UUID) (GalleryRecord, error) {
|
||||
id, err := pathUUID(c, "id")
|
||||
if err != nil {
|
||||
return GalleryRecord{}, err
|
||||
}
|
||||
return h.repository.GetForUser(r.Context(), userID, id)
|
||||
return h.repository.GetForUser(c.Request.Context(), userID, id)
|
||||
}
|
||||
|
||||
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
|
||||
@@ -628,8 +754,8 @@ func newSlug(title string) string {
|
||||
return slug + "-" + suffix
|
||||
}
|
||||
|
||||
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(r.PathValue(name))
|
||||
func pathUUID(c *gin.Context, name string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("invalid %s", name)
|
||||
}
|
||||
@@ -644,34 +770,32 @@ func stringPointer(value string) *string {
|
||||
return ©
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
func decodeJSON(c *gin.Context, target any) bool {
|
||||
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
|
||||
writeError(c, http.StatusUnsupportedMediaType, "content type must be application/json")
|
||||
return false
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
writeError(c, http.StatusBadRequest, "invalid JSON body")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeGalleryError(w http.ResponseWriter, err error) {
|
||||
func writeGalleryError(c *gin.Context, err error) {
|
||||
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
|
||||
writeError(w, http.StatusNotFound, "gallery not found")
|
||||
writeError(c, http.StatusNotFound, "gallery not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "could not load gallery")
|
||||
writeError(c, http.StatusInternalServerError, "could not load gallery")
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
func writeError(c *gin.Context, status int, message string) {
|
||||
writeJSON(c, status, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
func writeJSON(c *gin.Context, status int, value any) {
|
||||
c.JSON(status, value)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/example/sndit/backend/internal/auth"
|
||||
appdb "github.com/example/sndit/backend/internal/db"
|
||||
"github.com/example/sndit/backend/internal/media"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -40,13 +41,13 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
|
||||
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8)
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil {
|
||||
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Noah Bianchi"}`); err != nil {
|
||||
t.Fatalf("insert gallery: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx, `
|
||||
@@ -61,12 +62,12 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
t.Fatalf("create auth service: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
|
||||
mux := http.NewServeMux()
|
||||
handler.RegisterPublicRoutes(mux)
|
||||
router := gin.New()
|
||||
handler.RegisterPublicRoutes(router)
|
||||
|
||||
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(getRecorder, getRequest)
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
|
||||
favoriteRequest.AddCookie(cookie)
|
||||
}
|
||||
favoriteRecorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
router.ServeHTTP(favoriteRecorder, favoriteRequest)
|
||||
if favoriteRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
}
|
||||
|
||||
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
|
||||
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Noah Bianchi"); err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
repository := NewRepository(database)
|
||||
@@ -62,7 +62,7 @@ func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
|
||||
FavoritesEnabled: true,
|
||||
DownloadAllEnabled: true,
|
||||
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
|
||||
BrandingConfig: json.RawMessage(`{"studioName":"Noah Bianchi"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update gallery: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user