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>
243 lines
7.2 KiB
Go
243 lines
7.2 KiB
Go
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
|
|
}
|