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:
Michilis
2026-08-06 20:14:31 +00:00
co-authored by Cursor
parent 390e1dc0ea
commit be4dd5b47f
20 changed files with 962 additions and 56 deletions
+32 -1
View File
@@ -42,6 +42,13 @@ type photoURLs struct {
Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"`
Original string `json:"original"`
// Download / DownloadOriginal hit the download endpoint (files.go), which
// always streams the bytes same-origin instead of redirecting to S3, so a
// fetch() can read them into a Blob without CORS. Download serves the
// preview variant and doubles as the lightbox's <img> source, so the save
// fetch is answered from the HTTP cache rather than the network.
Download string `json:"download,omitempty"`
DownloadOriginal string `json:"downloadOriginal"`
}
type photoJSON struct {
@@ -51,6 +58,9 @@ type photoJSON struct {
OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
// PreviewSizeBytes lets the mobile save sheet label both of its rows with
// a real file size without fetching anything. Omitted while unknown.
PreviewSizeBytes int64 `json:"previewSizeBytes,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"`
@@ -91,6 +101,22 @@ func fileURL(photoID, variant, token string) string {
return u
}
// downloadURL builds the download endpoint's URL. size is left off for the
// preview, which the endpoint serves by default — a shorter, stabler string
// for the URL the lightbox also renders.
func downloadURL(photoID, size, token string) string {
u := "/api/photos/files/" + photoID + "/download"
sep := "?"
if size != "" && size != downloadSizePreview {
u += "?size=" + size
sep = "&"
}
if token != "" {
u += sep + "token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{
ID: p.ID,
@@ -101,16 +127,21 @@ func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON
SizeBytes: p.SizeBytes,
Width: p.Width,
Height: p.Height,
PreviewSizeBytes: p.PreviewSizeBytes,
TakenAt: isoTime(p.TakenAt),
Status: p.Status,
CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
URLs: photoURLs{
Original: fileURL(p.ID, "original", token),
DownloadOriginal: downloadURL(p.ID, downloadSizeOriginal, token),
},
}
if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
}
if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token)
out.URLs.Download = downloadURL(p.ID, downloadSizePreview, token)
}
if admin {
out.LastError = p.LastError