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:
@@ -750,3 +750,177 @@ func TestSlugCollision(t *testing.T) {
|
||||
t.Fatalf("default visibility: %s", a.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadEndpoint pins the guarantees the mobile save sheet is built
|
||||
// on: the bytes arrive same-origin (never a redirect), typed as an image,
|
||||
// named after the event, sized up front and cacheable forever.
|
||||
func TestDownloadEndpoint(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
g := createGallery(t, e, admin, map[string]any{
|
||||
"title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid,
|
||||
})
|
||||
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||
if processQueue(t, e, p.ID).Status != "ready" {
|
||||
t.Fatal("processing failed")
|
||||
}
|
||||
|
||||
base := "/api/photos/files/" + p.ID + "/download"
|
||||
for _, tc := range []struct {
|
||||
name, path, wantDisposition, wantFilename string
|
||||
}{
|
||||
// No ?size at all must behave exactly like ?size=preview.
|
||||
{"default", base, "inline", "spanglish-fiesta-1.jpg"},
|
||||
{"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"},
|
||||
{"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := e.request(t, "GET", tc.path, "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
|
||||
t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct)
|
||||
}
|
||||
if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" {
|
||||
t.Fatalf("content length %q, body %d bytes", n, w.Body.Len())
|
||||
}
|
||||
cd := w.Header().Get("Content-Disposition")
|
||||
if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) {
|
||||
t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("cache-control %q", cc)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each size serves its own variant byte-for-byte. (No size comparison
|
||||
// between the two here: the fixture is a synthetic 800x600 image, so its
|
||||
// 2048px preview re-encode is larger than the "original" — which says
|
||||
// nothing about the multi-megapixel photos the sheet exists for.)
|
||||
prev := e.request(t, "GET", base, "", nil)
|
||||
orig := e.request(t, "GET", base+"?size=original", "", nil)
|
||||
if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) {
|
||||
t.Fatal("download?size=preview must serve the same bytes as the preview variant")
|
||||
}
|
||||
if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) {
|
||||
t.Fatal("download?size=original must serve the same bytes as the original variant")
|
||||
}
|
||||
|
||||
// An unknown size is not a silent fallback.
|
||||
if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 {
|
||||
t.Fatalf("unknown size: want 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
// The gallery response labels both rows without any extra request.
|
||||
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
|
||||
got := detail.Photos[0]
|
||||
if got.PreviewSizeBytes != int64(prev.Body.Len()) {
|
||||
t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len())
|
||||
}
|
||||
if got.SizeBytes != int64(orig.Body.Len()) {
|
||||
t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len())
|
||||
}
|
||||
if got.URLs.Download != base {
|
||||
t.Fatalf("download url %q, want %q", got.URLs.Download, base)
|
||||
}
|
||||
if got.URLs.DownloadOriginal != base+"?size=original" {
|
||||
t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
// A restricted gallery's download must obey the same token check as every
|
||||
// other byte-serving route, and must not become shared-cacheable.
|
||||
func TestDownloadRespectsAccess(t *testing.T) {
|
||||
e := setup(t)
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"})
|
||||
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||
if processQueue(t, e, p.ID).Status != "ready" {
|
||||
t.Fatal("processing failed")
|
||||
}
|
||||
|
||||
detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
|
||||
url := detail.Photos[0].URLs.Download
|
||||
if !strings.Contains(url, "?token=v1.") {
|
||||
t.Fatalf("private download URL should carry a view token: %s", url)
|
||||
}
|
||||
// ?size and ?token have to coexist on the original's URL.
|
||||
if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") {
|
||||
t.Fatalf("private original download URL: %s", o)
|
||||
}
|
||||
|
||||
w := e.request(t, "GET", url, "", nil)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("view token fetch: want 200, got %d", w.Code)
|
||||
}
|
||||
// Restricted galleries stay out of shared caches; the browser's own
|
||||
// cache (which is what the save sheet reuses) still applies.
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
|
||||
t.Fatalf("cache-control %q must not be public for a private gallery", cc)
|
||||
}
|
||||
bare := strings.SplitN(url, "?", 2)[0]
|
||||
if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
|
||||
t.Fatalf("anon download without token: want 403, got %d", w.Code)
|
||||
}
|
||||
if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 {
|
||||
t.Fatalf("anon original download without token: want 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Photos processed before preview_size_bytes existed have no stored size.
|
||||
// The first gallery view measures them and writes the result back, so no
|
||||
// separate backfill command is needed for an existing library.
|
||||
func TestPreviewSizeBackfilledOnRead(t *testing.T) {
|
||||
e := setup(t)
|
||||
ctx := context.Background()
|
||||
admin := e.makeToken(t, uAdmin)
|
||||
g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"})
|
||||
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
|
||||
if processQueue(t, e, p.ID).Status != "ready" {
|
||||
t.Fatal("processing failed")
|
||||
}
|
||||
|
||||
// Rewind to what an pre-migration row looks like.
|
||||
if _, err := e.db.ExecContext(ctx, e.db.Rebind(
|
||||
"UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before, err := e.db.GetPhoto(ctx, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if before.PreviewSizeBytes != 0 {
|
||||
t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes)
|
||||
}
|
||||
|
||||
served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len()
|
||||
got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
|
||||
if got.Photos[0].PreviewSizeBytes != int64(served) {
|
||||
t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served)
|
||||
}
|
||||
// …and it was persisted, so the next view costs no Stat.
|
||||
after, err := e.db.GetPhoto(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.PreviewSizeBytes != int64(served) {
|
||||
t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served)
|
||||
}
|
||||
}
|
||||
|
||||
// Originals uploaded as HEIC or PNG must keep their real type and extension,
|
||||
// and a row with a missing/generic type must still go out as an image.
|
||||
func TestDownloadContentTypeNeverOctetStream(t *testing.T) {
|
||||
for _, tc := range []struct{ ct, name, want string }{
|
||||
{"image/heic", "IMG_1.HEIC", "image/heic"},
|
||||
{"image/png", "shot.png", "image/png"},
|
||||
{"application/octet-stream", "IMG_2.heic", "image/heic"},
|
||||
{"application/octet-stream", "scan.PNG", "image/png"},
|
||||
{"", "photo.jpeg", "image/jpeg"},
|
||||
{"", "", "image/jpeg"},
|
||||
} {
|
||||
if got := imageContentType(tc.ct, tc.name); got != tc.want {
|
||||
t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user