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 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/ 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 }