package httpapi import ( "errors" "fmt" "io" "log" "net/http" "os" "time" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" ) const presignExpiry = 15 * time.Minute // serveFile delivers photo bytes after re-running the gallery access check. // S3: 302 to a short-lived presigned URL; local: streamed directly. func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) { variant := r.PathValue("variant") if variant != "thumb" && variant != "preview" && variant != "original" { writeError(w, http.StatusNotFound, "Not Found") return } p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) if err != nil { writeStoreError(w, err, "Photo not found") return } g, err := s.db.GetGallery(r.Context(), p.GalleryID) if err != nil { writeStoreError(w, err, "Photo not found") return } user := s.optionalUser(r) token := r.URL.Query().Get("token") if denial := s.authorize(r, g, user, token); denial != nil { writeError(w, denial.status, denial.msg) return } isAdmin := user != nil && user.IsAdmin() var key, contentType, downloadName string switch variant { case "thumb": key, contentType = p.ThumbKey, "image/jpeg" case "preview": key, contentType = p.PreviewKey, "image/jpeg" case "original": key, contentType = p.OriginalKey, p.ContentType downloadName = p.OriginalFilename if downloadName == "" { downloadName = p.ID + extForContentType(p.ContentType) } } if key == "" { // Variant not generated yet (photo still processing). writeError(w, http.StatusNotFound, "Not ready") return } // Non-ready originals stay admin-only so uploads that fail processing // never leak to viewers. if p.Status != "ready" && !isAdmin { writeError(w, http.StatusNotFound, "Not ready") return } if url, err := s.storage.PresignGet(r.Context(), key, downloadName, contentType, presignExpiry); err == nil { http.Redirect(w, r, url, http.StatusFound) return } else if !errors.Is(err, storage.ErrNoPresign) { log.Printf("Error: presign %s: %v", key, err) writeError(w, http.StatusInternalServerError, "Internal Server Error") return } reader, size, err := s.storage.Open(r.Context(), key) if err != nil { if os.IsNotExist(err) { writeError(w, http.StatusNotFound, "Not Found") return } log.Printf("Error: open %s: %v", key, err) writeError(w, http.StatusInternalServerError, "Internal Server Error") return } defer reader.Close() w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) w.Header().Set("X-Content-Type-Options", "nosniff") // Keys are immutable (new upload = new key), so private caching is safe // even though access is checked per request. w.Header().Set("Cache-Control", "private, max-age=86400") if downloadName != "" { w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", downloadName)) } if _, err := io.Copy(w, reader); err != nil { log.Printf("stream %s: %v", key, err) } } func extForContentType(ct string) string { switch ct { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/gif": return ".gif" case "image/webp": return ".webp" case "image/heic": return ".heic" } return "" }