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>
199 lines
6.6 KiB
Go
199 lines
6.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
|
)
|
|
|
|
// JSON shapes are camelCase like the backend's responses.
|
|
|
|
type eventJSON struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
TitleEs string `json:"titleEs,omitempty"`
|
|
StartDatetime string `json:"startDatetime"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type galleryJSON struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
TitleEs string `json:"titleEs,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
DescriptionEs string `json:"descriptionEs,omitempty"`
|
|
EventID string `json:"eventId,omitempty"`
|
|
Visibility string `json:"visibility"`
|
|
CoverPhotoID string `json:"coverPhotoId,omitempty"`
|
|
PhotoCount int `json:"photoCount"`
|
|
CoverURL string `json:"coverUrl,omitempty"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
Event *eventJSON `json:"event,omitempty"`
|
|
// Admin-only fields:
|
|
ShareToken string `json:"shareToken,omitempty"`
|
|
ShareURL string `json:"shareUrl,omitempty"`
|
|
}
|
|
|
|
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 {
|
|
ID string `json:"id"`
|
|
GalleryID string `json:"galleryId"`
|
|
Position int `json:"position"`
|
|
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"`
|
|
Status string `json:"status"`
|
|
LastError string `json:"lastError,omitempty"` // admin only
|
|
CreatedAt string `json:"createdAt"`
|
|
URLs photoURLs `json:"urls"`
|
|
// Duplicate marks an upload that was skipped because the gallery already
|
|
// held these bytes; the rest of the object describes the existing photo.
|
|
// Set by the upload handler only, never persisted.
|
|
Duplicate bool `json:"duplicate,omitempty"`
|
|
}
|
|
|
|
// viewTokenFor returns the token to embed in a gallery's file URLs: none
|
|
// for public galleries (files are anonymously accessible), a short-lived
|
|
// gallery-scoped view token otherwise (see viewtoken.go).
|
|
func (s *Server) viewTokenFor(g store.Gallery) string {
|
|
if g.Visibility == store.VisibilityPublic {
|
|
return ""
|
|
}
|
|
return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID)
|
|
}
|
|
|
|
func isoTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
|
}
|
|
|
|
// fileURL builds the access-checked file endpoint URL; token is appended
|
|
// for link-mode viewers so the client can use URLs verbatim.
|
|
func fileURL(photoID, variant, token string) string {
|
|
u := "/api/photos/files/" + photoID + "/" + variant
|
|
if token != "" {
|
|
u += "?token=" + token
|
|
}
|
|
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,
|
|
GalleryID: p.GalleryID,
|
|
Position: p.Position,
|
|
OriginalFilename: p.OriginalFilename,
|
|
ContentType: p.ContentType,
|
|
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),
|
|
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
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
|
|
out := galleryJSON{
|
|
ID: g.ID,
|
|
Slug: g.Slug,
|
|
Title: g.Title,
|
|
TitleEs: g.TitleEs,
|
|
Description: g.Description,
|
|
DescriptionEs: g.DescriptionEs,
|
|
EventID: g.EventID,
|
|
Visibility: g.Visibility,
|
|
CoverPhotoID: g.CoverPhotoID,
|
|
PhotoCount: g.PhotoCount,
|
|
CreatedAt: isoTime(g.CreatedAt),
|
|
UpdatedAt: isoTime(g.UpdatedAt),
|
|
}
|
|
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
|
|
out.CoverURL = fileURL(coverID, "thumb", token)
|
|
}
|
|
if g.EventID != "" {
|
|
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
|
|
out.Event = &eventJSON{
|
|
ID: ev.ID,
|
|
Slug: ev.Slug,
|
|
Title: ev.Title,
|
|
TitleEs: ev.TitleEs,
|
|
StartDatetime: isoTime(ev.StartDatetime),
|
|
Status: ev.Status,
|
|
}
|
|
}
|
|
}
|
|
if admin {
|
|
out.ShareToken = g.ShareToken
|
|
// Event-linked galleries live under the event's URL; standalone
|
|
// galleries keep their own /photos/<slug> URL.
|
|
page := "/photos/" + g.Slug
|
|
if out.Event != nil {
|
|
page = "/events/" + out.Event.Slug + "/gallery"
|
|
}
|
|
out.ShareURL = s.cfg.FrontendURL + page
|
|
// The token only grants anything in link/ticket mode; public and
|
|
// private galleries get a clean URL.
|
|
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
|
|
out.ShareURL += "?token=" + g.ShareToken
|
|
}
|
|
}
|
|
return out
|
|
}
|