Files
MichilisandCursor be4dd5b47f Add mobile save sheet with same-origin photo downloads.
Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 20:14:31 +00:00

259 lines
8.4 KiB
Go

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-<event>-<n>.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 <img> 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 <a download> 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-<event-slug>-<n>.<ext>,
// 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 ""
}