Files
MichilisandCursor be4dd5b47f 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>
2026-08-06 20:14:31 +00:00

408 lines
13 KiB
Go

package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const maxAttempts = 5
type Photo struct {
ID string
GalleryID string
Position int
OriginalKey string
OriginalFilename string
ContentType string
SizeBytes int64
Width int
Height int
ThumbKey string
PreviewKey string
// PreviewSizeBytes is the size of the preview object, 0 when it has not
// been measured yet (rows written before the column existed).
PreviewSizeBytes int64
TakenAt time.Time
Status string
Attempts int
NextAttemptAt time.Time
LastError string
CreatedAt time.Time
UpdatedAt time.Time
// Checksum is the sha256 of the original bytes, empty when the row has
// not been hashed yet (uploaded before duplicate detection existed).
Checksum string
}
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
size_bytes, width, height, thumb_key, preview_key, preview_size_bytes, taken_at, status,
attempts, next_attempt_at, last_error, created_at, updated_at, checksum`
func scanPhoto(s scanner) (Photo, error) {
var v [20]any
dest := make([]any, len(v))
for i := range v {
dest[i] = &v[i]
}
if err := s.Scan(dest...); err != nil {
return Photo{}, err
}
return Photo{
ID: asString(v[0]),
GalleryID: asString(v[1]),
Position: int(asInt(v[2])),
OriginalKey: asString(v[3]),
OriginalFilename: asString(v[4]),
ContentType: asString(v[5]),
SizeBytes: asInt(v[6]),
Width: int(asInt(v[7])),
Height: int(asInt(v[8])),
ThumbKey: asString(v[9]),
PreviewKey: asString(v[10]),
PreviewSizeBytes: asInt(v[11]),
TakenAt: asTime(v[12]),
Status: asString(v[13]),
Attempts: int(asInt(v[14])),
NextAttemptAt: asTime(v[15]),
LastError: asString(v[16]),
CreatedAt: asTime(v[17]),
UpdatedAt: asTime(v[18]),
Checksum: asString(v[19]),
}, nil
}
// InsertPhoto returns an error satisfying IsUniqueViolation when the gallery
// already holds a photo with the same checksum; callers treat that as a
// duplicate rather than a failure.
func (db *DB) InsertPhoto(ctx context.Context, p Photo) error {
_, err := db.ExecContext(ctx, db.Rebind(`
INSERT INTO photos_photos
(id, gallery_id, position, original_key, original_filename, content_type, size_bytes,
status, attempts, next_attempt_at, created_at, updated_at, checksum)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?, ?)`),
p.ID, p.GalleryID, p.Position, p.OriginalKey, nullable(p.OriginalFilename), p.ContentType,
p.SizeBytes, db.TimeArg(p.NextAttemptAt), db.TimeArg(p.CreatedAt), db.TimeArg(p.UpdatedAt),
nullable(p.Checksum))
return err
}
// FindPhotoByChecksum looks for an existing photo with the same content in one
// gallery — the duplicate check the upload path runs before storing anything.
// Duplicate scope is per gallery: the same image in another gallery keeps its
// own row and its own stored object.
func (db *DB) FindPhotoByChecksum(ctx context.Context, galleryID, checksum string) (Photo, error) {
if checksum == "" {
return Photo{}, ErrNotFound // never match the not-yet-hashed rows
}
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT "+photoColumns+" FROM photos_photos WHERE gallery_id = ? AND checksum = ?"),
galleryID, checksum)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// SetPhotoChecksum fills in the hash of an already-stored photo. Used by the
// backfill; returns a unique-violation error when the row turns out to
// duplicate one already hashed in the same gallery.
func (db *DB) SetPhotoChecksum(ctx context.Context, id, checksum string) error {
res, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET checksum = ? WHERE id = ?"), checksum, id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotosMissingChecksum lists rows still lacking a hash (optionally of one
// gallery only), oldest first so backfill keeps the earliest upload of a
// duplicate pair as the one that gets the checksum.
func (db *DB) PhotosMissingChecksum(ctx context.Context, galleryID string) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE checksum IS NULL"
var args []any
if galleryID != "" {
q += " AND gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
photos := []Photo{}
for rows.Next() {
p, err := scanPhoto(rows)
if err != nil {
return nil, err
}
photos = append(photos, p)
}
return photos, rows.Err()
}
func (db *DB) GetPhoto(ctx context.Context, id string) (Photo, error) {
row := db.QueryRowContext(ctx,
db.Rebind("SELECT "+photoColumns+" FROM photos_photos WHERE id = ?"), id)
p, err := scanPhoto(row)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
return p, err
}
// ListPhotos returns a gallery's photos in position order. When readyOnly is
// set (public callers), photos still processing or failed are omitted.
func (db *DB) ListPhotos(ctx context.Context, galleryID string, readyOnly bool) ([]Photo, error) {
q := "SELECT " + photoColumns + " FROM photos_photos WHERE gallery_id = ?"
if readyOnly {
q += " AND status = 'ready'"
}
q += " ORDER BY position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
photos := []Photo{}
for rows.Next() {
p, err := scanPhoto(rows)
if err != nil {
return nil, err
}
photos = append(photos, p)
}
return photos, rows.Err()
}
func (db *DB) NextPosition(ctx context.Context, galleryID string) (int, error) {
var v any
err := db.QueryRowContext(ctx,
db.Rebind("SELECT COALESCE(MAX(position), -1) + 1 FROM photos_photos WHERE gallery_id = ?"),
galleryID).Scan(&v)
return int(asInt(v)), err
}
// ReorderPhotos rewrites positions to match ids order, in one transaction.
// ids must be exactly the gallery's photo ids (validated by the handler).
func (db *DB) ReorderPhotos(ctx context.Context, galleryID string, ids []string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := db.Rebind("UPDATE photos_photos SET position = ?, updated_at = ? WHERE id = ? AND gallery_id = ?")
now := db.TimeArg(time.Now())
for i, id := range ids {
if _, err := tx.ExecContext(ctx, q, i, now, id, galleryID); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) DeletePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_photos WHERE id = ?"), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// PhotoKeys returns every storage key of a gallery, for object cleanup
// before the rows cascade away.
func (db *DB) PhotoKeys(ctx context.Context, galleryID string) ([]string, error) {
rows, err := db.QueryContext(ctx, db.Rebind(
"SELECT original_key, thumb_key, preview_key FROM photos_photos WHERE gallery_id = ?"), galleryID)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var a, b, c any
if err := rows.Scan(&a, &b, &c); err != nil {
return nil, err
}
for _, k := range []string{asString(a), asString(b), asString(c)} {
if k != "" {
keys = append(keys, k)
}
}
}
return keys, rows.Err()
}
// PhotoObject is one stored object: a photo variant plus the content type it
// should be written with. Used by the storage sync, which treats the rows as
// the inventory of what exists.
type PhotoObject struct {
PhotoID string
GalleryID string
Variant string // original | thumb | preview
Key string
ContentType string
}
// AllPhotoObjects lists every object of every photo (optionally of one
// gallery only), oldest first. Variant keys are empty until the worker has
// processed the photo; those are left out.
func (db *DB) AllPhotoObjects(ctx context.Context, galleryID string) ([]PhotoObject, error) {
q := `SELECT id, gallery_id, content_type, original_key, thumb_key, preview_key
FROM photos_photos`
var args []any
if galleryID != "" {
q += " WHERE gallery_id = ?"
args = append(args, galleryID)
}
q += " ORDER BY gallery_id, position, created_at"
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
objects := []PhotoObject{}
for rows.Next() {
var id, galID, ctype, orig, thumb, preview any
if err := rows.Scan(&id, &galID, &ctype, &orig, &thumb, &preview); err != nil {
return nil, err
}
variants := []struct {
name, key, contentType string
}{
{"original", asString(orig), asString(ctype)},
{"thumb", asString(thumb), "image/jpeg"},
{"preview", asString(preview), "image/jpeg"},
}
for _, v := range variants {
if v.key == "" {
continue
}
objects = append(objects, PhotoObject{
PhotoID: asString(id),
GalleryID: asString(galID),
Variant: v.name,
Key: v.key,
ContentType: v.contentType,
})
}
}
return objects, rows.Err()
}
// ClaimNextPhoto picks the oldest due queued/failed photo and marks it
// processing. Optimistic claim (RowsAffected check) works identically on
// Postgres and SQLite; returns ErrNotFound when the queue is empty.
func (db *DB) ClaimNextPhoto(ctx context.Context) (Photo, error) {
now := time.Now()
var idRaw any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT id FROM photos_photos
WHERE status IN ('queued','failed') AND attempts < ? AND next_attempt_at <= ?
ORDER BY created_at LIMIT 1`),
maxAttempts, db.TimeArg(now)).Scan(&idRaw)
if errors.Is(err, sql.ErrNoRows) {
return Photo{}, ErrNotFound
}
if err != nil {
return Photo{}, err
}
id := asString(idRaw)
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'processing', attempts = attempts + 1, updated_at = ?
WHERE id = ? AND status IN ('queued','failed')`),
db.TimeArg(now), id)
if err != nil {
return Photo{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return Photo{}, ErrNotFound // lost the race; caller loops again
}
return db.GetPhoto(ctx, id)
}
func (db *DB) MarkPhotoReady(ctx context.Context, id, thumbKey, previewKey string, previewSize int64, width, height int, takenAt time.Time) error {
var takenArg any
if !takenAt.IsZero() {
takenArg = db.TimeArg(takenAt)
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos
SET status = 'ready', thumb_key = ?, preview_key = ?, preview_size_bytes = ?, width = ?,
height = ?, taken_at = ?, last_error = NULL, updated_at = ?
WHERE id = ?`),
thumbKey, previewKey, previewSize, width, height, takenArg, db.TimeArg(time.Now()), id)
return err
}
// SetPreviewSize records the measured size of an already-generated preview.
// Used to fill in rows processed before the column existed; failure to write
// is not worth failing a read over, so callers may ignore the error.
func (db *DB) SetPreviewSize(ctx context.Context, id string, size int64) error {
_, err := db.ExecContext(ctx, db.Rebind(
"UPDATE photos_photos SET preview_size_bytes = ? WHERE id = ?"), size, id)
return err
}
func (db *DB) MarkPhotoFailed(ctx context.Context, id string, attempts int, cause error) error {
backoff := time.Duration(1<<min(attempts, 6)) * time.Minute
msg := fmt.Sprintf("%v", cause)
if len(msg) > 1000 {
msg = msg[:1000]
}
_, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'failed', last_error = ?, next_attempt_at = ?, updated_at = ?
WHERE id = ?`),
msg, db.TimeArg(time.Now().Add(backoff)), db.TimeArg(time.Now()), id)
return err
}
// RequeuePhoto resets a failed photo for a fresh round of attempts.
func (db *DB) RequeuePhoto(ctx context.Context, id string) error {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', attempts = 0, next_attempt_at = ?, updated_at = ?
WHERE id = ? AND status IN ('failed','queued')`),
db.TimeArg(time.Now()), db.TimeArg(time.Now()), id)
if err != nil {
return err
}
return errIfNoRows(res)
}
// RecoverStuckProcessing requeues photos left in 'processing' by a crash.
func (db *DB) RecoverStuckProcessing(ctx context.Context, olderThan time.Duration) (int64, error) {
res, err := db.ExecContext(ctx, db.Rebind(`
UPDATE photos_photos SET status = 'queued', updated_at = ?
WHERE status = 'processing' AND updated_at < ?`),
db.TimeArg(time.Now()), db.TimeArg(time.Now().Add(-olderThan)))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// GalleryCoverKeys returns thumb keys for cover selection: the explicit
// cover photo's thumb when set and ready, else the first ready photo's.
func (db *DB) GalleryCoverKey(ctx context.Context, g Gallery) (photoID, thumbKey string) {
if g.CoverPhotoID != "" {
if p, err := db.GetPhoto(ctx, g.CoverPhotoID); err == nil && p.Status == "ready" && p.GalleryID == g.ID {
return p.ID, p.ThumbKey
}
}
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT id, thumb_key FROM photos_photos
WHERE gallery_id = ? AND status = 'ready'
ORDER BY position, created_at LIMIT 1`), g.ID)
var idRaw, keyRaw any
if err := row.Scan(&idRaw, &keyRaw); err != nil {
return "", ""
}
return asString(idRaw), asString(keyRaw)
}