Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
6.4 KiB
Go
226 lines
6.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
func validVisibility(v string) bool {
|
|
switch v {
|
|
case store.VisibilityPublic, store.VisibilityPrivate, store.VisibilityLink, store.VisibilityTicket:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type createGalleryBody struct {
|
|
Title string `json:"title"`
|
|
TitleEs string `json:"titleEs"`
|
|
Description string `json:"description"`
|
|
DescriptionEs string `json:"descriptionEs"`
|
|
EventID string `json:"eventId"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
func (s *Server) createGallery(w http.ResponseWriter, r *http.Request, user auth.User) {
|
|
var body createGalleryBody
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
|
return
|
|
}
|
|
if body.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "title is required")
|
|
return
|
|
}
|
|
if body.Visibility == "" {
|
|
body.Visibility = store.VisibilityPrivate
|
|
}
|
|
if !validVisibility(body.Visibility) {
|
|
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
|
|
return
|
|
}
|
|
if body.EventID != "" {
|
|
if _, err := s.db.GetEventSummary(r.Context(), body.EventID); err != nil {
|
|
writeStoreError(w, err, "Event not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
slug, err := s.uniqueSlug(r.Context(), body.Title)
|
|
if err != nil {
|
|
writeStoreError(w, err, "")
|
|
return
|
|
}
|
|
now := time.Now()
|
|
g := store.Gallery{
|
|
ID: newID(),
|
|
Slug: slug,
|
|
Title: body.Title,
|
|
TitleEs: body.TitleEs,
|
|
Description: body.Description,
|
|
DescriptionEs: body.DescriptionEs,
|
|
EventID: body.EventID,
|
|
Visibility: body.Visibility,
|
|
ShareToken: newShareToken(),
|
|
CreatedBy: user.ID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := s.db.CreateGallery(r.Context(), g); err != nil {
|
|
writeStoreError(w, err, "")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{
|
|
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
|
})
|
|
}
|
|
|
|
func (s *Server) listGalleries(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
|
limit := queryInt(r, "limit", 100)
|
|
offset := queryInt(r, "offset", 0)
|
|
galleries, err := s.db.ListGalleries(r.Context(), r.URL.Query().Get("eventId"), limit, offset)
|
|
if err != nil {
|
|
writeStoreError(w, err, "")
|
|
return
|
|
}
|
|
out := make([]galleryJSON, 0, len(galleries))
|
|
for _, g := range galleries {
|
|
out = append(out, s.galleryToJSON(r.Context(), g, "", true))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"galleries": out})
|
|
}
|
|
|
|
func (s *Server) getGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
|
g, err := s.db.GetGallery(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
photos, err := s.db.ListPhotos(r.Context(), g.ID, false)
|
|
if err != nil {
|
|
writeStoreError(w, err, "")
|
|
return
|
|
}
|
|
out := make([]photoJSON, 0, len(photos))
|
|
for _, p := range photos {
|
|
out = append(out, s.photoToJSON(p, "", true))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
|
"photos": out,
|
|
})
|
|
}
|
|
|
|
type updateGalleryBody struct {
|
|
Title *string `json:"title"`
|
|
TitleEs *string `json:"titleEs"`
|
|
Description *string `json:"description"`
|
|
DescriptionEs *string `json:"descriptionEs"`
|
|
EventID *string `json:"eventId"`
|
|
Visibility *string `json:"visibility"`
|
|
CoverPhotoID *string `json:"coverPhotoId"`
|
|
}
|
|
|
|
func (s *Server) updateGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
|
id := r.PathValue("id")
|
|
var body updateGalleryBody
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
|
return
|
|
}
|
|
if body.Title != nil && *body.Title == "" {
|
|
writeError(w, http.StatusBadRequest, "title cannot be empty")
|
|
return
|
|
}
|
|
if body.Visibility != nil && !validVisibility(*body.Visibility) {
|
|
writeError(w, http.StatusBadRequest, "visibility must be public, private, link or ticket")
|
|
return
|
|
}
|
|
if body.EventID != nil && *body.EventID != "" {
|
|
if _, err := s.db.GetEventSummary(r.Context(), *body.EventID); err != nil {
|
|
writeStoreError(w, err, "Event not found")
|
|
return
|
|
}
|
|
}
|
|
if body.CoverPhotoID != nil && *body.CoverPhotoID != "" {
|
|
p, err := s.db.GetPhoto(r.Context(), *body.CoverPhotoID)
|
|
if err != nil || p.GalleryID != id {
|
|
writeError(w, http.StatusBadRequest, "coverPhotoId must be a photo of this gallery")
|
|
return
|
|
}
|
|
}
|
|
|
|
err := s.db.UpdateGallery(r.Context(), id, store.GalleryUpdate{
|
|
Title: body.Title,
|
|
TitleEs: body.TitleEs,
|
|
Description: body.Description,
|
|
DescriptionEs: body.DescriptionEs,
|
|
EventID: body.EventID,
|
|
Visibility: body.Visibility,
|
|
CoverPhotoID: body.CoverPhotoID,
|
|
})
|
|
if err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
g, err := s.db.GetGallery(r.Context(), id)
|
|
if err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
|
})
|
|
}
|
|
|
|
func (s *Server) deleteGallery(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
|
id := r.PathValue("id")
|
|
keys, err := s.db.PhotoKeys(r.Context(), id)
|
|
if err != nil {
|
|
writeStoreError(w, err, "")
|
|
return
|
|
}
|
|
if err := s.db.DeleteGallery(r.Context(), id); err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
// Object cleanup is best-effort after the rows are gone; orphaned
|
|
// objects are harmless (unreachable) and logged for manual sweep.
|
|
for _, key := range keys {
|
|
if err := s.storage.Delete(r.Context(), key); err != nil {
|
|
log.Printf("delete object %s: %v", key, err)
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Gallery deleted"})
|
|
}
|
|
|
|
func (s *Server) rotateShareToken(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
|
id := r.PathValue("id")
|
|
if err := s.db.RotateShareToken(r.Context(), id, newShareToken()); err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
g, err := s.db.GetGallery(r.Context(), id)
|
|
if err != nil {
|
|
writeStoreError(w, err, "Gallery not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"gallery": s.galleryToJSON(r.Context(), g, "", true),
|
|
})
|
|
}
|
|
|
|
func queryInt(r *http.Request, key string, def int) int {
|
|
if v := r.URL.Query().Get(key); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
return n
|
|
}
|
|
}
|
|
return def
|
|
}
|