Add photo galleries API and frontend for public and admin viewing.
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>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/imaging"
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// uploadPhotos accepts multipart form data with one or more "files" parts.
|
||||
// Each file is sniffed by magic bytes (client filename/Content-Type are
|
||||
// untrusted, same policy as /api/media/upload), stored as the untouched
|
||||
// original, and queued for variant processing.
|
||||
func (s *Server) uploadPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
galleryID := r.PathValue("id")
|
||||
g, err := s.db.GetGallery(r.Context(), galleryID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Gallery not found")
|
||||
return
|
||||
}
|
||||
|
||||
maxFile := int64(s.cfg.MaxUploadMB) << 20
|
||||
// Generous request ceiling; nginx enforces its own client_max_body_size.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 40*maxFile)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Expected multipart/form-data")
|
||||
return
|
||||
}
|
||||
|
||||
position, err := s.db.NextPosition(r.Context(), g.ID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
|
||||
var created []photoJSON
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Malformed multipart body")
|
||||
return
|
||||
}
|
||||
if part.FormName() != "files" && part.FormName() != "file" {
|
||||
part.Close()
|
||||
continue
|
||||
}
|
||||
photo, uploadErr := s.saveUpload(r, g, part, position, maxFile)
|
||||
part.Close()
|
||||
if uploadErr != nil {
|
||||
// One bad file fails the request explicitly rather than silently
|
||||
// skipping it; the admin UI uploads files individually.
|
||||
writeError(w, uploadErr.status, uploadErr.msg)
|
||||
return
|
||||
}
|
||||
created = append(created, s.photoToJSON(photo, "", true))
|
||||
position++
|
||||
}
|
||||
|
||||
if len(created) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "No file provided")
|
||||
return
|
||||
}
|
||||
s.worker.Nudge()
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"photos": created})
|
||||
}
|
||||
|
||||
type uploadError struct {
|
||||
status int
|
||||
msg string
|
||||
}
|
||||
|
||||
func (s *Server) saveUpload(r *http.Request, g store.Gallery, part *multipart.Part, position int, maxFile int64) (store.Photo, *uploadError) {
|
||||
head := make([]byte, 16)
|
||||
n, err := io.ReadFull(part, head)
|
||||
if err != nil && err != io.ErrUnexpectedEOF {
|
||||
return store.Photo{}, &uploadError{http.StatusBadRequest, "Could not read file"}
|
||||
}
|
||||
head = head[:n]
|
||||
contentType, ext, ok, reason := imaging.Sniff(head)
|
||||
if !ok {
|
||||
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType, reason}
|
||||
}
|
||||
if contentType == "image/heic" && imaging.DetectHeicConverter(s.cfg.HeicConverter) == nil {
|
||||
return store.Photo{}, &uploadError{http.StatusUnsupportedMediaType,
|
||||
"HEIC uploads need an image converter on the server (install libvips-tools); please upload JPEG instead"}
|
||||
}
|
||||
|
||||
// Spool to a temp file to learn the size before handing to storage
|
||||
// (S3 wants a length; local rename wants a file anyway).
|
||||
tmp, err := os.CreateTemp(s.cfg.StoragePath, ".incoming-*")
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer tmp.Close()
|
||||
|
||||
size, err := io.Copy(tmp, io.MultiReader(bytes.NewReader(head), io.LimitReader(part, maxFile+1)))
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
if size > maxFile {
|
||||
return store.Photo{}, &uploadError{http.StatusRequestEntityTooLarge,
|
||||
fmt.Sprintf("File exceeds the %d MB limit", s.cfg.MaxUploadMB)}
|
||||
}
|
||||
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
|
||||
photoID := newID()
|
||||
key := fmt.Sprintf("galleries/%s/orig/%s%s", g.ID, photoID, ext)
|
||||
if err := s.storage.Put(r.Context(), key, tmp, size, contentType); err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
photo := store.Photo{
|
||||
ID: photoID,
|
||||
GalleryID: g.ID,
|
||||
Position: position,
|
||||
OriginalKey: key,
|
||||
OriginalFilename: sanitizeFilename(part.FileName()),
|
||||
ContentType: contentType,
|
||||
SizeBytes: size,
|
||||
Status: "queued",
|
||||
NextAttemptAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.InsertPhoto(r.Context(), photo); err != nil {
|
||||
s.storage.Delete(r.Context(), key)
|
||||
log.Printf("Error: %v", err)
|
||||
return store.Photo{}, &uploadError{http.StatusInternalServerError, "Internal Server Error"}
|
||||
}
|
||||
return photo, nil
|
||||
}
|
||||
|
||||
type reorderBody struct {
|
||||
PhotoIDs []string `json:"photoIds"`
|
||||
}
|
||||
|
||||
func (s *Server) reorderPhotos(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
galleryID := r.PathValue("id")
|
||||
var body reorderBody
|
||||
if err := decodeJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Invalid JSON body")
|
||||
return
|
||||
}
|
||||
existing, err := s.db.ListPhotos(r.Context(), galleryID, false)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
writeStoreError(w, store.ErrNotFound, "Gallery not found or empty")
|
||||
return
|
||||
}
|
||||
current := map[string]bool{}
|
||||
for _, p := range existing {
|
||||
current[p.ID] = true
|
||||
}
|
||||
if len(body.PhotoIDs) != len(existing) {
|
||||
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range body.PhotoIDs {
|
||||
if !current[id] || seen[id] {
|
||||
writeError(w, http.StatusBadRequest, "photoIds must contain every photo of the gallery exactly once")
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if err := s.db.ReorderPhotos(r.Context(), galleryID, body.PhotoIDs); err != nil {
|
||||
writeStoreError(w, err, "")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Order updated"})
|
||||
}
|
||||
|
||||
func (s *Server) deletePhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId"))
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
if err := s.db.DeletePhoto(r.Context(), p.ID); err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
for _, key := range []string{p.OriginalKey, p.ThumbKey, p.PreviewKey} {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
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": "Photo deleted"})
|
||||
}
|
||||
|
||||
func (s *Server) retryPhoto(w http.ResponseWriter, r *http.Request, _ auth.User) {
|
||||
id := r.PathValue("photoId")
|
||||
if err := s.db.RequeuePhoto(r.Context(), id); err != nil {
|
||||
writeStoreError(w, err, "Photo not found or not failed")
|
||||
return
|
||||
}
|
||||
s.worker.Nudge()
|
||||
p, err := s.db.GetPhoto(r.Context(), id)
|
||||
if err != nil {
|
||||
writeStoreError(w, err, "Photo not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"photo": s.photoToJSON(p, "", true)})
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
if name == "." || name == "/" {
|
||||
return ""
|
||||
}
|
||||
if len(name) > 200 {
|
||||
name = name[len(name)-200:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
Reference in New Issue
Block a user