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>
This commit is contained in:
@@ -7,13 +7,24 @@ import (
|
||||
"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) {
|
||||
@@ -22,23 +33,10 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
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")
|
||||
p, _, ok := s.authorizedPhoto(w, r)
|
||||
if !ok {
|
||||
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 {
|
||||
@@ -58,12 +56,6 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -100,6 +92,155 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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":
|
||||
|
||||
Reference in New Issue
Block a user