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 }