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>
259 lines
8.4 KiB
Go
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 ""
|
|
}
|