feat(metadata): SQLite backend, storage reconciliation and local defaults
- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls - SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx) - separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite - cmd/reconcile compares object storage with the journal (report, then --delete) - storage.Backend.List and photos.Repository.AllStorageKeys - DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults (sqlite ./galo.db, fs ./data) - Makefile reconcile/reconcile-delete; docs and .env.example updated
This commit is contained in:
@@ -368,3 +368,21 @@ func (s *Store) FailVariantJob(ctx context.Context, photoID, errMsg string, maxA
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// AllStorageKeys returns every key registered in the object journal.
|
||||
func (s *Store) AllStorageKeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT storage_key FROM objects`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/models"
|
||||
)
|
||||
|
||||
// Repository is the persistence contract for the photos domain, including the
|
||||
// object ledger, variant queue and garbage collection.
|
||||
type Repository interface {
|
||||
CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error
|
||||
DeleteUpload(ctx context.Context, photoID, objectID string) error
|
||||
GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error)
|
||||
List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
SetDeleted(ctx context.Context, id string, deleted bool) error
|
||||
UpdateAlbum(ctx context.Context, id, albumID string) error
|
||||
Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error
|
||||
HardDelete(ctx context.Context, id string) error
|
||||
ListTrash(ctx context.Context, userID string) ([]models.Photo, error)
|
||||
TrashCount(ctx context.Context, userID string) (int, error)
|
||||
RestoreToAlbum(ctx context.Context, id, albumID string) error
|
||||
AlbumExists(ctx context.Context, albumID string) (bool, error)
|
||||
ListTags(ctx context.Context, userID, q string) ([]models.Tag, error)
|
||||
EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error)
|
||||
AttachTag(ctx context.Context, photoID, tagID string) error
|
||||
DetachTag(ctx context.Context, photoID, tagID string) error
|
||||
TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error)
|
||||
SetCoverIfEmpty(ctx context.Context, albumID, photoID string)
|
||||
UpdateFilename(ctx context.Context, id, filename string) error
|
||||
BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error)
|
||||
|
||||
CreateObject(ctx context.Context, o *Object) error
|
||||
SetObjectState(ctx context.Context, id, state string) error
|
||||
ActivateObject(ctx context.Context, id string) error
|
||||
MarkObjectDeleting(ctx context.Context, id string) error
|
||||
DeleteObjectRow(ctx context.Context, id string) error
|
||||
BindObject(ctx context.Context, photoID, objectID, role string) error
|
||||
PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error)
|
||||
ObjectRefCount(ctx context.Context, objectID string) (int, error)
|
||||
OriginalObject(ctx context.Context, photoID string) (*Object, error)
|
||||
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
|
||||
ClaimGarbage(ctx context.Context, limit int) ([]Object, error)
|
||||
ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error)
|
||||
ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error)
|
||||
CompleteVariantJob(ctx context.Context, photoID string) error
|
||||
FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error
|
||||
|
||||
// AllStorageKeys returns every key registered in the object journal.
|
||||
AllStorageKeys(ctx context.Context) ([]string, error)
|
||||
}
|
||||
@@ -22,13 +22,13 @@ type AlbumAccess interface {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store *Store
|
||||
store Repository
|
||||
albums AlbumAccess
|
||||
storage storage.Backend
|
||||
hub *sse.Hub
|
||||
}
|
||||
|
||||
func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
||||
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
||||
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"shared-album/internal/sqliteutil"
|
||||
)
|
||||
|
||||
func nullIfEmpty(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return sqliteutil.FormatTime(*t)
|
||||
}
|
||||
|
||||
func nullFloat(f *float64) any {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
func nowUTC() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,365 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/sqliteutil"
|
||||
)
|
||||
|
||||
// CreateObject inserts a new object row in the pending state.
|
||||
func (s *SQLiteStore) CreateObject(ctx context.Context, o *Object) error {
|
||||
if o.State == "" {
|
||||
o.State = ObjectPending
|
||||
}
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING created_at`,
|
||||
o.ID, o.StorageKey, o.MimeType, o.Size, o.Width, o.Height, o.Checksum, o.State).Scan(&createdAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetObjectState updates an object's lifecycle state.
|
||||
func (s *SQLiteStore) SetObjectState(ctx context.Context, id, state string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE objects SET state = $2 WHERE id = $1`, id, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateObject marks an object ready after its bytes have been stored.
|
||||
func (s *SQLiteStore) ActivateObject(ctx context.Context, id string) error {
|
||||
return s.SetObjectState(ctx, id, ObjectReady)
|
||||
}
|
||||
|
||||
// MarkObjectDeleting marks an object as pending removal from storage.
|
||||
func (s *SQLiteStore) MarkObjectDeleting(ctx context.Context, id string) error {
|
||||
return s.SetObjectState(ctx, id, ObjectDeleting)
|
||||
}
|
||||
|
||||
// DeleteObjectRow permanently removes an object row (after its blob is gone).
|
||||
func (s *SQLiteStore) DeleteObjectRow(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BindObject attaches a ready object to a photo under the given role. The write
|
||||
// lock of the immediate transaction serializes it against garbage collection.
|
||||
func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var state string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if state != ObjectReady {
|
||||
return ErrObjectNotReady
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (photo_id, role) DO UPDATE SET object_id = excluded.object_id`,
|
||||
photoID, objectID, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// PhotoObjectIDs returns the object ids currently bound to a photo.
|
||||
func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ObjectRefCount counts how many photo bindings reference an object.
|
||||
func (s *SQLiteStore) ObjectRefCount(ctx context.Context, objectID string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM photo_objects WHERE object_id = $1`, objectID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// OriginalObject returns the original object bound to a photo.
|
||||
func (s *SQLiteStore) OriginalObject(ctx context.Context, photoID string) (*Object, error) {
|
||||
var o Object
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
|
||||
FROM photo_objects po JOIN objects o ON o.id = po.object_id
|
||||
WHERE po.photo_id = $1 AND po.role = $2`, photoID, RoleOriginal).
|
||||
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// PhotosByOriginalObject returns the ids of all photos bound to the given
|
||||
// original object, so generated variants can be shared between copies.
|
||||
func (s *SQLiteStore) PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT photo_id FROM photo_objects WHERE object_id = $1 AND role = $2`, originalObjectID, RoleOriginal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// ClaimGarbage claims collectible objects: ready objects with no bindings and
|
||||
// objects already marked deleting.
|
||||
func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT o.id, o.storage_key FROM objects o
|
||||
WHERE o.state IN ('ready', 'deleting')
|
||||
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
|
||||
ORDER BY o.created_at
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objs, err := scanObjectKeys(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
if err := markDeleting(ctx, tx, objs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
// ClaimStalePending marks abandoned pending objects as deleting and returns them.
|
||||
func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
threshold := sqliteutil.FormatTime(time.Now().UTC().Add(-olderThan))
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT id, storage_key FROM objects
|
||||
WHERE state = 'pending' AND created_at < $1
|
||||
ORDER BY created_at
|
||||
LIMIT $2`, threshold, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objs, err := scanObjectKeys(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
if err := markDeleting(ctx, tx, objs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return objs, nil
|
||||
}
|
||||
|
||||
// ClaimVariantJobs leases due variant jobs and returns their photo ids.
|
||||
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
now := sqliteutil.FormatTime(time.Now().UTC())
|
||||
next := sqliteutil.FormatTime(time.Now().UTC().Add(lease))
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT photo_id FROM variant_jobs
|
||||
WHERE next_attempt_at <= $1
|
||||
ORDER BY next_attempt_at
|
||||
LIMIT $2`, now, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, next)
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE variant_jobs SET next_attempt_at = $1 WHERE photo_id IN (`+sqliteutil.Placeholders(2, len(ids))+`)`,
|
||||
args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// CompleteVariantJob removes a finished job.
|
||||
func (s *SQLiteStore) CompleteVariantJob(ctx context.Context, photoID string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID)
|
||||
return err
|
||||
}
|
||||
|
||||
// FailVariantJob records a failure and schedules a retry with exponential
|
||||
// backoff, abandoning the job after maxAttempts.
|
||||
func (s *SQLiteStore) FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var attempts int
|
||||
err = tx.QueryRowContext(ctx, `SELECT attempts FROM variant_jobs WHERE photo_id = $1`, photoID).Scan(&attempts)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return tx.Commit()
|
||||
}
|
||||
return err
|
||||
}
|
||||
newAttempts := attempts + 1
|
||||
if maxAttempts > 0 && newAttempts >= maxAttempts {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
secs := 10 * (1 << attempts)
|
||||
if secs > 300 {
|
||||
secs = 300
|
||||
}
|
||||
next := sqliteutil.FormatTime(time.Now().UTC().Add(time.Duration(secs) * time.Second))
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE variant_jobs SET attempts = $2, next_attempt_at = $3, last_error = $4 WHERE photo_id = $1`,
|
||||
photoID, newAttempts, next, errMsg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func scanObjectKeys(rows *sql.Rows) ([]Object, error) {
|
||||
var objs []Object
|
||||
for rows.Next() {
|
||||
var o Object
|
||||
if err := rows.Scan(&o.ID, &o.StorageKey); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
objs = append(objs, o)
|
||||
}
|
||||
rows.Close()
|
||||
return objs, rows.Err()
|
||||
}
|
||||
|
||||
func markDeleting(ctx context.Context, tx *sql.Tx, objs []Object) error {
|
||||
args := make([]any, len(objs))
|
||||
for i, o := range objs {
|
||||
args[i] = o.ID
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`UPDATE objects SET state = 'deleting' WHERE id IN (`+sqliteutil.Placeholders(1, len(objs))+`)`, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// AllStorageKeys returns every key registered in the object journal.
|
||||
func (s *SQLiteStore) AllStorageKeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT storage_key FROM objects`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/sqliteutil"
|
||||
)
|
||||
|
||||
// SQLiteStore is the SQLite implementation of Repository.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
||||
|
||||
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
||||
COALESCE(o_o.storage_key, ''),
|
||||
COALESCE(o_t.storage_key, ''),
|
||||
COALESCE(o_p.storage_key, ''),
|
||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
|
||||
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
|
||||
COALESCE(p.exif, '{}'), p.deleted_at,
|
||||
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
|
||||
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2
|
||||
ORDER BY lower(t.name)) t), '[]')`
|
||||
|
||||
const sqlitePhotoJoins = `
|
||||
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
|
||||
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
|
||||
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
|
||||
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
|
||||
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
||||
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
|
||||
|
||||
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
var exifText, tagsText, createdAt string
|
||||
var takenAt, deletedAt sql.NullString
|
||||
var lat, long sql.NullFloat64
|
||||
var liked int
|
||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
if lat.Valid {
|
||||
p.Latitude = &lat.Float64
|
||||
}
|
||||
if long.Valid {
|
||||
p.Longitude = &long.Float64
|
||||
}
|
||||
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
||||
p.LikedByMe = liked != 0
|
||||
setExif(&p, exifText)
|
||||
setTags(&p, tagsText)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
|
||||
exifJSON, err := json.Marshal(p.EXIF)
|
||||
if err != nil || len(exifJSON) == 0 {
|
||||
exifJSON = []byte("{}")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
|
||||
return err
|
||||
}
|
||||
var createdAt string
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING created_at`,
|
||||
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
|
||||
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
|
||||
).Scan(&createdAt); err != nil {
|
||||
return err
|
||||
}
|
||||
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
|
||||
p.ID, obj.ID, RoleOriginal); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT `+sqlitePhotoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
||||
WHERE p.id = $1`, id, viewerID)
|
||||
p, err := scanSQLitePhoto(row.Scan)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
|
||||
if in.Limit <= 0 || in.Limit > 100 {
|
||||
in.Limit = 50
|
||||
}
|
||||
var since any
|
||||
if in.Since != nil {
|
||||
since = sqliteutil.FormatTime(*in.Since)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+sqlitePhotoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
|
||||
WHERE p.album_id = $1
|
||||
AND p.deleted_at IS NULL
|
||||
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
|
||||
AND ($4 IS NULL OR p.created_at > $4)
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT $5`,
|
||||
in.AlbumID, in.Viewer, in.Cursor, since, in.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
p, err := scanSQLitePhoto(rows.Scan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error {
|
||||
var res sql.Result
|
||||
var err error
|
||||
if deleted {
|
||||
res, err = s.db.ExecContext(ctx,
|
||||
`UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`,
|
||||
id, sqliteutil.FormatTime(nowUTC()))
|
||||
} else {
|
||||
res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
|
||||
exifJSON, err := json.Marshal(src.EXIF)
|
||||
if err != nil || len(exifJSON) == 0 {
|
||||
exifJSON = []byte("{}")
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
|
||||
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO photo_objects (photo_id, object_id, role)
|
||||
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
|
||||
id, src.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '')
|
||||
FROM photos p
|
||||
JOIN users u ON u.id = p.uploader_id
|
||||
LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+`
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)
|
||||
ORDER BY p.deleted_at DESC
|
||||
LIMIT 200`,
|
||||
userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.Photo{}
|
||||
for rows.Next() {
|
||||
var p models.Photo
|
||||
var exifText, tagsText, createdAt, albumTitle string
|
||||
var takenAt, deletedAt sql.NullString
|
||||
var lat, long sql.NullFloat64
|
||||
var liked int
|
||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||
&p.ThumbKey, &p.PreviewKey,
|
||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
|
||||
if lat.Valid {
|
||||
p.Latitude = &lat.Float64
|
||||
}
|
||||
if long.Valid {
|
||||
p.Longitude = &long.Float64
|
||||
}
|
||||
p.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
|
||||
p.AlbumTitle = albumTitle
|
||||
p.LikedByMe = liked != 0
|
||||
setExif(&p, exifText)
|
||||
setTags(&p, tagsText)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM photos p
|
||||
LEFT JOIN albums a ON a.id = p.album_id
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
|
||||
OR (p.album_id IS NULL AND p.uploader_id = $1)
|
||||
)`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) {
|
||||
var ok int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
|
||||
return ok != 0, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name FROM tags
|
||||
WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%')
|
||||
ORDER BY lower(name) LIMIT 50`, userID, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.Tag{}
|
||||
for rows.Next() {
|
||||
var t models.Tag
|
||||
if err := rows.Scan(&t.ID, &t.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 64 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var t models.Tag
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||
Scan(&t.ID, &t.Name)
|
||||
if err == nil {
|
||||
return &t, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
id := auth.NewID()
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name).
|
||||
Scan(&t.ID, &t.Name)
|
||||
if err != nil {
|
||||
if e2 := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
|
||||
Scan(&t.ID, &t.Name); e2 == nil {
|
||||
return &t, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
|
||||
var ok int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
|
||||
return ok != 0, err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.db.ExecContext(ctx,
|
||||
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
||||
albumID, photoID)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
|
||||
// of every photo with empty exif.
|
||||
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT p.id, o.storage_key
|
||||
FROM photos p
|
||||
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
|
||||
JOIN objects o ON o.id = po.object_id
|
||||
WHERE p.exif IS NULL OR p.exif = '{}'
|
||||
ORDER BY p.created_at`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var pending []backfillRow
|
||||
for rows.Next() {
|
||||
var r backfillRow
|
||||
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
pending = append(pending, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, r := range pending {
|
||||
data, err := fetch(ctx, r.StorageKey)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta := extractEXIF(data)
|
||||
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
|
||||
continue
|
||||
}
|
||||
attrs, err := json.Marshal(meta.Attributes)
|
||||
if err != nil || len(attrs) == 0 {
|
||||
attrs = []byte("{}")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE photos SET exif = $2,
|
||||
taken_at = COALESCE(taken_at, $3),
|
||||
latitude = COALESCE(latitude, $4),
|
||||
longitude = COALESCE(longitude, $5)
|
||||
WHERE id = $1`,
|
||||
r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil {
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
Reference in New Issue
Block a user