Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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
|
||||
TakenAt time.Time
|
||||
Status string
|
||||
Attempts int
|
||||
NextAttemptAt time.Time
|
||||
LastError string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const photoColumns = `id, gallery_id, position, original_key, original_filename, content_type,
|
||||
size_bytes, width, height, thumb_key, preview_key, taken_at, status, attempts, next_attempt_at,
|
||||
last_error, created_at, updated_at`
|
||||
|
||||
func scanPhoto(s scanner) (Photo, error) {
|
||||
var v [18]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]),
|
||||
TakenAt: asTime(v[11]),
|
||||
Status: asString(v[12]),
|
||||
Attempts: int(asInt(v[13])),
|
||||
NextAttemptAt: asTime(v[14]),
|
||||
LastError: asString(v[15]),
|
||||
CreatedAt: asTime(v[16]),
|
||||
UpdatedAt: asTime(v[17]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
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))
|
||||
return 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()
|
||||
}
|
||||
|
||||
// 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, 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 = ?, width = ?, height = ?, taken_at = ?,
|
||||
last_error = NULL, updated_at = ?
|
||||
WHERE id = ?`),
|
||||
thumbKey, previewKey, width, height, takenArg, db.TimeArg(time.Now()), 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)
|
||||
}
|
||||
Reference in New Issue
Block a user