package httpapi import ( "errors" "fmt" "io" "log" "net/http" "os" "strings" "time" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/store" ) const presignExpiry = 15 * time.Minute // Sizes the download endpoint offers. Preview is the default because it is // what a phone can actually save to its photo library: iOS and Android only // reach the gallery through the share sheet, and the share sheet is worth // handing a 2048px JPEG rather than a multi-megabyte original. const ( downloadSizePreview = "preview" downloadSizeOriginal = "original" ) // 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, _, ok := s.authorizedPhoto(w, r) if !ok { return } 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 } 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) } } // serveDownload backs the gallery's save/download actions. It differs from // serveFile in three ways that the mobile save flow depends on: // // - It always streams. serveFile 302s to S3 when presigning is available, // and a cross-origin redirect is opaque to fetch() — the client could // never read the bytes into a Blob to hand to navigator.share(). // - It names the file (spanglish--.jpg) and always declares a // real image/* type, because iOS only offers "Save Image" in the share // sheet for something it recognises as an image. // - It caches for a year as immutable. ?size=preview is also the URL the // lightbox renders, so tapping save re-reads the bytes already on screen // out of the HTTP cache instead of fetching them again. func (s *Server) serveDownload(w http.ResponseWriter, r *http.Request) { size := r.URL.Query().Get("size") if size == "" { size = downloadSizePreview } if size != downloadSizePreview && size != downloadSizeOriginal { writeError(w, http.StatusNotFound, "Not Found") return } p, g, ok := s.authorizedPhoto(w, r) if !ok { return } key, contentType := p.PreviewKey, "image/jpeg" if size == downloadSizeOriginal { key, contentType = p.OriginalKey, imageContentType(p.ContentType, p.OriginalFilename) } if key == "" { writeError(w, http.StatusNotFound, "Not ready") return } reader, n, err := s.storage.Open(r.Context(), key) if err != nil { if os.IsNotExist(err) || errors.Is(err, storage.ErrNotExist) { 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", n)) w.Header().Set("X-Content-Type-Options", "nosniff") // The preview is rendered as an as well as saved, so it is marked // inline; only the original is a pure download. Either way the filename // travels with it, which is what a bare fallback and the // Android share sheet display. disposition := "inline" if size == downloadSizeOriginal { disposition = "attachment" } w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q", disposition, s.downloadFilename(r, g, p, size))) // Objects are immutable (a new upload writes a new key), so the response // can be cached for as long as the browser will keep it. Restricted // galleries stay out of shared caches: access is re-checked per request // here, and only the browser that passed the check may reuse the bytes. scope := "private" if g.Visibility == store.VisibilityPublic { scope = "public" } w.Header().Set("Cache-Control", scope+", max-age=31536000, immutable") if _, err := io.Copy(w, reader); err != nil { // Routine on mobile: the client aborts the fetch when the save sheet // is cancelled, which lands here as a broken pipe. log.Printf("stream %s: %v", key, err) } } // authorizedPhoto loads the photo and its gallery and re-runs the access // check every byte-serving handler owes (PLAN.md §7). It writes the error // response itself; ok=false means the caller is done. func (s *Server) authorizedPhoto(w http.ResponseWriter, r *http.Request) (store.Photo, store.Gallery, bool) { p, err := s.db.GetPhoto(r.Context(), r.PathValue("photoId")) if err != nil { writeStoreError(w, err, "Photo not found") return store.Photo{}, store.Gallery{}, false } g, err := s.db.GetGallery(r.Context(), p.GalleryID) if err != nil { writeStoreError(w, err, "Photo not found") return store.Photo{}, store.Gallery{}, false } user := s.optionalUser(r) if denial := s.authorize(r, g, user, r.URL.Query().Get("token")); denial != nil { writeError(w, denial.status, denial.msg) return store.Photo{}, store.Gallery{}, false } // Non-ready originals stay admin-only so uploads that fail processing // never leak to viewers. if p.Status != "ready" && !(user != nil && user.IsAdmin()) { writeError(w, http.StatusNotFound, "Not ready") return store.Photo{}, store.Gallery{}, false } return p, g, true } // downloadFilename names the saved file after the event it came from rather // than the photographer's IMG_1234.JPG: spanglish--., // numbered from 1 in gallery order. Standalone galleries use their own slug. func (s *Server) downloadFilename(r *http.Request, g store.Gallery, p store.Photo, size string) string { slug := g.Slug if g.EventID != "" { if ev, err := s.db.GetEventSummary(r.Context(), g.EventID); err == nil && ev.Slug != "" { slug = ev.Slug } } ext := ".jpg" if size == downloadSizeOriginal { if e := extForContentType(p.ContentType); e != "" { ext = e } } return fmt.Sprintf("spanglish-%s-%d%s", slug, p.Position+1, ext) } // imageContentType keeps the download endpoint's Content-Type in the image/* // family. An original stored with a missing or generic type would otherwise // go out as application/octet-stream, and iOS drops "Save Image" from the // share sheet for anything it cannot see as an image. func imageContentType(ct, filename string) string { if strings.HasPrefix(ct, "image/") { return ct } if i := strings.LastIndex(filename, "."); i >= 0 { switch strings.ToLower(filename[i:]) { case ".jpg", ".jpeg": return "image/jpeg" case ".png": return "image/png" case ".gif": return "image/gif" case ".webp": return "image/webp" case ".heic", ".heif": return "image/heic" } } return "image/jpeg" } 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 "" }