Author SHA1 Message Date
iu 9329ebc023 commit 2026-10-10 14:21:43 +03:00
217 changed files with 1364 additions and 16024 deletions
+5 -23
View File
@@ -6,31 +6,18 @@ PG_PASSWORD=change_me
PG_DATABASE=shared_album
PG_SSLMODE=disable
# Metadata backend: sqlite (default, embedded single instance) or postgres
DB_BACKEND=sqlite
# Path to the SQLite database file (used when DB_BACKEND=sqlite)
SQLITE_PATH=./galo.db
# S3 (Garage on s3.localnet) - internal storage, never exposed to clients
# S3 (Garage on s3.localnet)
S3_ENDPOINT=http://s3.localnet:3900
S3_REGION=garage
S3_ACCESS_KEY=GKchange_me
S3_SECRET_KEY=change_me
S3_BUCKET=galo-data
S3_USE_PATH_STYLE=true
S3_PUBLIC_URL=http://s3.localnet:3900/galo-data
S3_PRESIGN_TTL=1h
# Storage backend: fs (default, local filesystem) or s3 (S3-compatible/Garage).
# Blobs are always delivered through the backend via /api/objects/<key>.
STORAGE_BACKEND=fs
# Root directory for object blobs (STORAGE_BACKEND=fs)
FS_ROOT=./data
# Signs object URLs; falls back to the metadata-layer secret when empty
URL_SECRET=
# Lifetime of signed object URLs
OBJECT_URL_TTL=1h
# Auth. The token signing secret is generated on first start and stored in the
# metadata layer (app_settings); it is not configured here.
# Auth
JWT_SECRET=change_me_long_random
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=720h
@@ -41,8 +28,3 @@ APP_BASE_URL=http://localhost:8080
# SSE
SSE_HEARTBEAT_INTERVAL=25s
SSE_CLIENT_BUFFER=64
# Background worker (variant generation + object GC)
WORKER_INTERVAL=5s
WORKER_BATCH=10
WORKER_PENDING_TTL=10m
-5
View File
@@ -7,8 +7,3 @@ frontend/build/
frontend/.svelte-kit/
node_modules/
*.log
# Local data backends (defaults)
galo.db
galo.db-*
data/
+1 -10
View File
@@ -1,4 +1,4 @@
.PHONY: build frontend backend run migrate reconcile reconcile-delete tidy test clean
.PHONY: build frontend backend run migrate tidy test clean
frontend:
cd frontend && npm ci && npm run build
@@ -20,15 +20,6 @@ run: backend
migrate:
cd backend && go run ./cmd/migrate
# Reconcile object storage against the metadata journal (report only).
reconcile:
cd backend && go run ./cmd/reconcile
# Reconcile and delete orphaned blobs older than --min-age (default 1h).
# Override the grace period when needed: make reconcile-delete MIN_AGE=1m
reconcile-delete:
cd backend && go run ./cmd/reconcile --delete $(if $(MIN_AGE),--min-age $(MIN_AGE),)
tidy:
cd backend && go mod tidy
+4 -31
View File
@@ -30,19 +30,8 @@ cp .env.example .env
```
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион
`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся
в окружении: он генерируется при первом старте и хранится в слое метаданных
(`app_settings`).
Слои данных выбираются параметрами (по умолчанию — локальный режим):
- `STORAGE_BACKEND=fs|s3` — где лежат блобы: `fs` (по умолчанию, `FS_ROOT=./data`)
или `s3` (S3/Garage); наружу всегда отдаётся бэкенд (`/api/objects/<key>`).
- `DB_BACKEND=sqlite|postgres` — где лежат метаданные: `sqlite` (по умолчанию,
`SQLITE_PATH=./galo.db`) или `postgres`. SQLite — встроенный режим без внешней БД,
**только для одного экземпляра приложения на файл** (без горизонтального
масштабирования).
`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`.
## Сборка
@@ -105,21 +94,6 @@ TEST_DATABASE_URL='postgres://user:pass@pg.localnet:5432/galo?sslmode=disable' \
cd backend && go test ./internal/repo/ -v
```
## Реконсиляция хранилища
Сборка мусора воркера управляется журналом `objects` и не сканирует хранилище, поэтому
блобы без строки в журнале (после сброса БД в обход журнала и т.п.) накапливаются.
Ручная сверка:
```bash
go run ./cmd/reconcile # отчёт: сироты (есть в хранилище, нет в журнале) и пропавшие
go run ./cmd/reconcile --delete # удалить сироты старше --min-age (по умолчанию 1h)
```
- По умолчанию ничего не удаляется; удаление только по явному `--delete`.
- Область — префикс `objects/`; блобы старого формата `albums/` не затрагиваются.
- Пропавшие ключи (в журнале есть, в хранилище нет) только сообщаются.
## Деплой через systemd
1. Соберите бинарник: `make build`.
@@ -155,12 +129,11 @@ backend/
cmd/migrate/ # применение миграций
internal/config # чтение env
internal/db # pgx + golang-migrate
internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд
internal/store # фабрика репозиториев (postgres / sqlite)
internal/storage # S3 (Garage)
internal/auth # JWT, bcrypt, refresh
internal/albums # альбомы
internal/members # участники, роли, инвайты
internal/photos # загрузка, варианты, отдача через бэкенд
internal/photos # загрузка, варианты, presigned URL
internal/social # лайки, комментарии
internal/notifications # персональные уведомления
internal/sharelinks # ссылки для просмотра
-82
View File
@@ -1,82 +0,0 @@
package main
import (
"bufio"
"context"
"log/slog"
"os"
"strings"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/settings"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "error", err)
os.Exit(1)
}
ctx := context.Background()
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
defer pool.Close()
repos := store.NewPostgres(pool)
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
slog.Error("secret init failed", "error", err)
os.Exit(1)
}
blob, err := storage.Open(ctx, cfg, secret)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
updated, err := repos.Photos.BackfillEXIF(ctx, blob.Get)
if err != nil {
slog.Error("backfill failed", "error", err)
os.Exit(1)
}
slog.Info("exif backfill complete", "updated", updated)
}
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
}
-169
View File
@@ -1,169 +0,0 @@
// Command reconcile compares the object storage with the metadata journal and
// reports orphaned blobs (present in storage, absent from the journal). With
// --delete it removes orphans older than --min-age. It never touches missing
// keys (in the journal but not in storage) and never runs automatically.
package main
import (
"bufio"
"context"
"flag"
"io/fs"
"log/slog"
"os"
"strings"
"time"
assets "shared-album"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/reconcile"
"shared-album/internal/settings"
"shared-album/internal/storage"
"shared-album/internal/store"
)
const objectPrefix = "objects/"
func main() {
loadDotEnv(".env")
loadDotEnv("../.env")
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
deleteFlag := flag.Bool("delete", false, "delete orphaned blobs older than --min-age")
minAge := flag.Duration("min-age", time.Hour, "minimum blob age before deletion is allowed")
sample := flag.Int("sample", 20, "number of example keys to print")
flag.Parse()
cfg, err := config.Load()
if err != nil {
slog.Error("config error", "error", err)
os.Exit(1)
}
ctx := context.Background()
repos, closer, err := openRepos(ctx, cfg)
if err != nil {
slog.Error("metadata open failed", "error", err)
os.Exit(1)
}
defer closer()
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
slog.Error("secret init failed", "error", err)
os.Exit(1)
}
blob, err := storage.Open(ctx, cfg, secret)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
objects, err := blob.List(ctx, objectPrefix)
if err != nil {
slog.Error("list storage", "error", err)
os.Exit(1)
}
journaled, err := repos.Photos.AllStorageKeys(ctx)
if err != nil {
slog.Error("list journal", "error", err)
os.Exit(1)
}
res := reconcile.Classify(objects, journaled)
slog.Info("reconciliation report",
"storage", cfg.Storage.Backend,
"storage_objects", len(objects),
"journal_keys", len(journaled),
"orphans", len(res.Orphans),
"missing", len(res.Missing))
printSamples("orphan", res.Orphans, *sample)
printSamples("missing", res.Missing, *sample)
if !*deleteFlag {
slog.Info("dry run: nothing deleted (pass --delete to remove orphans)")
return
}
targets := reconcile.Deletable(objects, res, objectPrefix, *minAge, time.Now())
if len(targets) == 0 {
slog.Info("no orphaned blobs old enough to delete", "min_age", minAge.String())
return
}
if err := blob.Delete(ctx, targets...); err != nil {
slog.Error("delete orphans", "error", err)
os.Exit(1)
}
slog.Info("deleted orphaned blobs", "count", len(targets), "min_age", minAge.String())
}
// openRepos builds the metadata repositories for the configured backend.
func openRepos(ctx context.Context, cfg *config.Config) (store.Repos, func(), error) {
if cfg.Database.Backend == "sqlite" {
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
return store.Repos{}, nil, err
}
if err := db.MigrateSQLite(cfg.Database.SQLitePath, migFS); err != nil {
return store.Repos{}, nil, err
}
database, err := db.OpenSQLite(cfg.Database.SQLitePath)
if err != nil {
return store.Repos{}, nil, err
}
return store.NewSQLite(database), func() { _ = database.Close() }, nil
}
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
return store.Repos{}, nil, err
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
return store.Repos{}, nil, err
}
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
return store.Repos{}, nil, err
}
return store.NewPostgres(pool), pool.Close, nil
}
func printSamples(label string, keys []string, n int) {
if len(keys) == 0 {
return
}
if n > len(keys) {
n = len(keys)
}
for _, k := range keys[:n] {
slog.Info("sample", "kind", label, "key", k)
}
}
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, value)
}
}
}
+18 -73
View File
@@ -15,13 +15,10 @@ import (
assets "shared-album"
"shared-album/internal/app"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/db"
"shared-album/internal/settings"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func main() {
@@ -37,92 +34,40 @@ func main() {
os.Exit(1)
}
switch cfg.Database.Backend {
case "sqlite":
slog.Info("metadata backend", "backend", "sqlite", "path", cfg.Database.SQLitePath)
default:
slog.Info("metadata backend", "backend", "postgres",
"host", cfg.Postgres.Host, "database", cfg.Postgres.Database)
}
switch cfg.Storage.Backend {
case "fs":
slog.Info("storage backend", "backend", "fs", "root", cfg.Storage.FSRoot)
default:
slog.Info("storage backend", "backend", "s3",
"endpoint", cfg.S3.Endpoint, "bucket", cfg.S3.Bucket)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
var (
repos store.Repos
ping func(ctx context.Context) error
closer func()
)
switch cfg.Database.Backend {
case "sqlite":
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.MigrateSQLite(cfg.Database.SQLitePath, migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
database, err := db.OpenSQLite(cfg.Database.SQLitePath)
if err != nil {
slog.Error("database open failed", "error", err)
os.Exit(1)
}
repos = store.NewSQLite(database)
ping = database.PingContext
closer = func() { _ = database.Close() }
default:
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
repos = store.NewPostgres(pool)
ping = pool.Ping
closer = pool.Close
}
defer closer()
slog.Info("migrations applied", "db_backend", cfg.Database.Backend)
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
slog.Error("secret init failed", "error", err)
slog.Error("migrations fs", "error", err)
os.Exit(1)
}
tm := auth.NewTokenManager(secret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil {
slog.Error("migration failed", "error", err)
os.Exit(1)
}
slog.Info("migrations applied")
blob, err := storage.Open(ctx, cfg, secret)
pool, err := db.Connect(ctx, cfg.Postgres.DSN())
if err != nil {
slog.Error("database connect failed", "error", err)
os.Exit(1)
}
defer pool.Close()
store, err := storage.New(ctx, cfg.S3)
if err != nil {
slog.Error("storage init failed", "error", err)
os.Exit(1)
}
if err := blob.EnsureBucket(ctx); err != nil {
if err := store.EnsureBucket(ctx); err != nil {
slog.Warn("could not ensure bucket", "error", err)
} else {
slog.Info("storage ready", "location", blob.Bucket())
slog.Info("storage bucket ready", "bucket", store.Bucket())
}
hub := sse.NewHub(cfg.SSE.ClientBuffer)
server := app.New(cfg, repos, ping, tm, blob, hub)
go server.StartWorker(ctx)
server := app.New(cfg, pool, store, hub)
httpServer := &http.Server{
Addr: cfg.Server.Addr,
+2 -2
View File
@@ -7,7 +7,7 @@ import "embed"
//go:embed all:static
var Static embed.FS
// Migrations holds the SQL migration files for every supported backend.
// Migrations holds the SQL migration files.
//
//go:embed migrations/*.sql migrations/sqlite/*.sql
//go:embed migrations/*.sql
var Migrations embed.FS
+2 -14
View File
@@ -9,15 +9,14 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
github.com/aws/smithy-go v1.28.5
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/webp v0.6.4
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.20.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.60.1
)
require (
@@ -38,19 +37,14 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -58,14 +52,12 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
@@ -76,8 +68,4 @@ require (
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+5 -99
View File
@@ -68,25 +68,6 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
@@ -99,11 +80,6 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -124,19 +100,11 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.20.1 h1:2N/ToVTKrKl58ynBpgeVJ4In7VcLCjWTZtm4eP1LxhU=
github.com/golang-migrate/migrate/v4 v4.20.1/go.mod h1:DDPgKVb4ovSWc4FwSPfV2Uz1160f4XBiTHTrAJtljmM=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -147,22 +115,16 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
@@ -178,8 +140,6 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -195,10 +155,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -231,75 +189,23 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
modernc.org/ccgo/v4 v4.36.1/go.mod h1:rrtGc2QkS239nYb/mQNuBMyjq3/y3ZXWbBjPoV3wqzA=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
-18
View File
@@ -1,18 +0,0 @@
package albums
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the albums domain.
type Repository interface {
Create(ctx context.Context, a *models.Album) error
OwnerID(ctx context.Context, id string) (string, error)
GetByID(ctx context.Context, id string) (*models.Album, error)
ListForUser(ctx context.Context, userID string) ([]models.Album, error)
Update(ctx context.Context, a *models.Album) error
Delete(ctx context.Context, id string) error
TrashPhotosByAlbum(ctx context.Context, albumID string) error
}
+36 -28
View File
@@ -17,15 +17,20 @@ type MemberLookup interface {
Role(ctx context.Context, albumID, userID string) (string, error)
}
type Service struct {
store Repository
members MemberLookup
hub *sse.Hub
storage storage.Backend
type PhotoLookup interface {
PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error)
}
func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
return &Service{store: store, members: members, hub: hub, storage: storage}
type Service struct {
store *Store
members MemberLookup
photos PhotoLookup
hub *sse.Hub
storage *storage.Client
}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client, photos PhotoLookup) *Service {
return &Service{store: store, members: members, photos: photos, hub: hub, storage: storage}
}
type CreateInput struct {
@@ -51,23 +56,22 @@ func (s *Service) Create(ctx context.Context, userID string, in CreateInput) (*m
}
func (s *Service) List(ctx context.Context, userID string) ([]models.Album, error) {
out, err := s.store.ListForUser(ctx, userID)
list, err := s.store.ListForUser(ctx, userID)
if err != nil {
return nil, err
}
for i := range out {
s.attachCover(ctx, &out[i])
for i := range list {
s.attachCoverURL(ctx, &list[i])
}
return out, nil
return list, nil
}
// attachCover fills CoverThumbURL from the signed thumbnail URL, when present.
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
func (s *Service) attachCoverURL(ctx context.Context, a *models.Album) {
if a == nil || a.CoverThumbKey == "" || s.storage == nil {
return
}
if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil {
a.CoverThumbURL = url
if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil {
a.CoverURL = url
}
}
@@ -79,6 +83,7 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
}
return nil, "", err
}
s.attachCoverURL(ctx, a)
if a.OwnerID == userID {
return a, roles.Owner, nil
}
@@ -93,12 +98,7 @@ func (s *Service) Access(ctx context.Context, albumID, userID string) (*models.A
}
func (s *Service) Get(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
a, role, err := s.Access(ctx, albumID, userID)
if err != nil {
return nil, "", err
}
s.attachCover(ctx, a)
return a, role, nil
return s.Access(ctx, albumID, userID)
}
type UpdateInput struct {
@@ -129,15 +129,23 @@ func (s *Service) Update(ctx context.Context, albumID, userID string, in UpdateI
if in.ClearCover {
a.CoverPhotoID = nil
} else if in.CoverPhotoID != nil {
ok, err := s.photos.PhotoInAlbum(ctx, *in.CoverPhotoID, albumID)
if err != nil {
return nil, err
}
if !ok {
return nil, httpx.BadRequest("invalid_cover", "cover photo must belong to the album")
}
a.CoverPhotoID = in.CoverPhotoID
}
if err := s.store.Update(ctx, a); err != nil {
return nil, err
}
if refreshed, err := s.store.GetByID(ctx, a.ID); err == nil {
a = refreshed
if fresh, err := s.store.GetByID(ctx, albumID); err == nil {
a.CoverThumbKey = fresh.CoverThumbKey
a.CoverURL = ""
s.attachCoverURL(ctx, a)
}
s.attachCover(ctx, a)
s.hub.Publish(albumID, "album.updated", a)
return a, nil
}
@@ -150,12 +158,12 @@ func (s *Service) Delete(ctx context.Context, albumID, userID string) error {
if role != roles.Owner {
return httpx.Forbidden("forbidden", "only the owner can delete the album")
}
if err := s.store.TrashPhotosByAlbum(ctx, a.ID); err != nil {
return err
}
if err := s.store.Delete(ctx, a.ID); err != nil {
return err
}
if s.storage != nil {
_ = s.storage.DeletePrefix(ctx, "albums/"+a.ID+"/")
}
s.hub.Publish(albumID, "album.deleted", map[string]string{"id": a.ID})
return nil
}
-142
View File
@@ -1,142 +0,0 @@
package albums
import (
"context"
"database/sql"
"errors"
"time"
"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} }
func (s *SQLiteStore) Create(ctx context.Context, a *models.Album) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO albums (id, owner_id, title, description, cover_photo_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at, updated_at`,
a.ID, a.OwnerID, a.Title, a.Description, a.CoverPhotoID,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
var ownerID string
err := s.db.QueryRowContext(ctx, `SELECT owner_id FROM albums WHERE id = $1`, id).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return ownerID, nil
}
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
a.created_at, a.updated_at, COALESCE(co.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
const sqliteAlbumJoins = `
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id`
func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
var a models.Album
var cover sql.NullString
var createdAt, updatedAt string
if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt,
&a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err
}
if cover.Valid {
a.CoverPhotoID = &cover.String
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &a, nil
}
func (s *SQLiteStore) GetByID(ctx context.Context, id string) (*models.Album, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a`+sqliteAlbumJoins+` WHERE a.id = $1`, id)
a, err := scanSQLiteAlbum(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return a, nil
}
func (s *SQLiteStore) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1`+sqliteAlbumJoins+`
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Album{}
for rows.Next() {
a, err := scanSQLiteAlbum(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *SQLiteStore) Update(ctx context.Context, a *models.Album) error {
var updatedAt string
err := s.db.QueryRowContext(ctx,
`UPDATE albums
SET title = $2, description = $3, cover_photo_id = $4, updated_at = $5
WHERE id = $1
RETURNING updated_at`,
a.ID, a.Title, a.Description, a.CoverPhotoID, sqliteutil.FormatTime(time.Now().UTC()),
).Scan(&updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM albums WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE photos SET deleted_at = $2 WHERE album_id = $1 AND deleted_at IS NULL`,
albumID, sqliteutil.FormatTime(time.Now().UTC()))
return err
}
+9 -24
View File
@@ -43,15 +43,11 @@ func (s *Store) OwnerID(ctx context.Context, id string) (string, error) {
func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
var a models.Album
err := s.pool.QueryRow(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
FROM albums a
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id
WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount)
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
a.created_at, a.updated_at
FROM albums a WHERE a.id = $1`, id).
Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
@@ -63,14 +59,11 @@ func (s *Store) GetByID(ctx context.Context, id string) (*models.Album, error) {
func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.pool.Query(ctx,
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id, a.created_at, a.updated_at,
COALESCE(co.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)
`SELECT a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
COALESCE((SELECT p.thumb_key FROM photos p WHERE p.id = a.cover_photo_id), ''),
a.created_at, a.updated_at
FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
@@ -81,7 +74,7 @@ func (s *Store) ListForUser(ctx context.Context, userID string) ([]models.Album,
out := []models.Album{}
for rows.Next() {
var a models.Album
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CreatedAt, &a.UpdatedAt, &a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
if err := rows.Scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &a.CoverPhotoID, &a.CoverThumbKey, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, err
}
out = append(out, a)
@@ -109,11 +102,3 @@ func (s *Store) Delete(ctx context.Context, id string) error {
}
return nil
}
// TrashPhotosByAlbum soft-deletes all photos of an album so they can be restored
// or permanently removed from the trash after the album is gone.
func (s *Store) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE album_id = $1 AND deleted_at IS NULL`, albumID)
return err
}
+30 -70
View File
@@ -1,14 +1,13 @@
package app
import (
"context"
"errors"
"io/fs"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
@@ -22,24 +21,22 @@ import (
"shared-album/internal/social"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
type Server struct {
cfg *config.Config
repos store.Repos
ping func(ctx context.Context) error
storage storage.Backend
pool *pgxpool.Pool
storage *storage.Client
hub *sse.Hub
albums *albums.Service
albumsStore *albums.Store
albums *albums.Service
auth *auth.Service
membersSvc *members.Service
photos *photos.Service
worker *photos.Worker
social *social.Service
@@ -48,47 +45,45 @@ type Server struct {
share *sharelinks.Service
}
// New wires the domain services over the selected metadata repositories. ping
// reports the health of the metadata backend so health stays backend-agnostic.
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server {
authSvc := auth.NewService(repos.Auth, tm)
func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hub) *Server {
authStore := auth.NewStore(pool)
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
authSvc := auth.NewService(authStore, tm)
notifSvc := notifications.NewService(repos.Notifications, hub)
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
photosStore := photos.NewStore(pool)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st, photosStore)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
socialStore := social.NewStore(pool)
socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc)
shareStore := sharelinks.NewStore(pool)
shareSvc := sharelinks.NewService(shareStore, albumsSvc)
return &Server{
cfg: cfg,
repos: repos,
ping: ping,
pool: pool,
storage: st,
hub: hub,
albumsStore: albumsStore,
albums: albumsSvc,
auth: authSvc,
membersSvc: membersSvc,
photos: photosSvc,
worker: photosWorker,
social: socialSvc,
notifications: notifSvc,
share: shareSvc,
}
}
// StartWorker runs the background reconciler until ctx is cancelled.
func (s *Server) StartWorker(ctx context.Context) {
if s.worker != nil {
s.worker.Run(ctx)
}
}
func (s *Server) Router() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
@@ -103,8 +98,6 @@ func (s *Server) Router() *gin.Engine {
api := r.Group("/api")
api.GET("/health", s.health)
api.GET("/objects/*key", s.serveObject)
api.HEAD("/objects/*key", s.serveObject)
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
@@ -122,7 +115,6 @@ func (s *Server) Router() *gin.Engine {
authed.Use(s.auth.Middleware())
authed.GET("/auth/me", authHandler.Me)
authed.PATCH("/auth/me/preferences", authHandler.UpdatePreferences)
authed.GET("/sse/albums/:id", s.albumSSE)
authed.GET("/sse/me", s.meSSE)
@@ -141,20 +133,8 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/albums/:id/photos", photosHandler.List)
authed.POST("/albums/:id/photos", photosHandler.Upload)
authed.GET("/photos/:id", photosHandler.Get)
authed.PATCH("/photos/:id", photosHandler.Rename)
authed.DELETE("/photos/:id", photosHandler.Delete)
authed.GET("/photos/:id/download", photosHandler.Download)
authed.POST("/photos/:id/restore", photosHandler.Restore)
authed.DELETE("/photos/:id/permanent", photosHandler.PermanentDelete)
authed.POST("/photos/:id/tags", photosHandler.AddTag)
authed.DELETE("/photos/:id/tags/:tag_id", photosHandler.RemoveTag)
authed.GET("/tags", photosHandler.Tags)
authed.POST("/photo-batch/delete", photosHandler.DeleteBatch)
authed.POST("/photo-batch/move", photosHandler.MoveBatch)
authed.POST("/photo-batch/copy", photosHandler.CopyBatch)
authed.GET("/trash", photosHandler.Trash)
authed.GET("/trash/count", photosHandler.TrashCount)
authed.POST("/photos/:id/likes", socialHandler.AddLike)
authed.DELETE("/photos/:id/likes", socialHandler.RemoveLike)
@@ -176,40 +156,20 @@ func (s *Server) Router() *gin.Engine {
func (s *Server) health(c *gin.Context) {
ctx := c.Request.Context()
dbStatus := "ok"
if err := s.ping(ctx); err != nil {
if err := s.pool.Ping(ctx); err != nil {
dbStatus = "error"
}
storageLabel := s.cfg.Storage.Backend
if storageLabel == "" {
storageLabel = "s3"
}
storeStatus := "ok"
s3Status := "ok"
if err := s.storage.Ping(ctx); err != nil {
storeStatus = "error"
s3Status = "error"
}
status := http.StatusOK
overall := "ok"
if dbStatus != "ok" || storeStatus != "ok" {
if dbStatus != "ok" || s3Status != "ok" {
status = http.StatusServiceUnavailable
overall = "degraded"
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus})
}
func (s *Server) serveObject(c *gin.Context) {
key := strings.TrimPrefix(c.Param("key"), "/")
switch err := s.storage.ServeObject(c.Writer, c.Request, key); {
case err == nil:
return
case errors.Is(err, storage.ErrSignedURLInvalid):
c.Status(http.StatusForbidden)
case errors.Is(err, storage.ErrObjectNotFound):
httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found"))
case errors.Is(err, storage.ErrRangeNotSatisfiable):
c.Status(http.StatusRequestedRangeNotSatisfiable)
default:
c.Status(http.StatusInternalServerError)
}
c.JSON(status, gin.H{"status": overall, "db": dbStatus, "s3": s3Status})
}
func (s *Server) albumSSE(c *gin.Context) {
@@ -236,7 +196,7 @@ func (s *Server) shareAlbum(c *gin.Context) {
httpx.RespondError(c, err)
return
}
album, err := s.repos.Albums.GetByID(c.Request.Context(), link.AlbumID)
album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID)
if err != nil {
httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found"))
return
-61
View File
@@ -1,61 +0,0 @@
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"shared-album/internal/config"
"shared-album/internal/storage"
)
func mustSigner(t *testing.T) *storage.Signer {
t.Helper()
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
return signer
}
func TestHealthStorageLabel(t *testing.T) {
gin.SetMode(gin.TestMode)
fs, err := storage.NewFS(t.TempDir(), mustSigner(t))
if err != nil {
t.Fatalf("fs: %v", err)
}
ok := func(context.Context) error { return nil }
srv := &Server{cfg: &config.Config{}, ping: ok, storage: fs}
call := func() map[string]any {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/health", nil)
srv.health(c)
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
return body
}
srv.cfg.Storage.Backend = "fs"
if body := call(); body["fs"] != "ok" {
t.Fatalf("expected fs label ok, got %v", body)
}
srv.cfg.Storage.Backend = "s3"
if body := call(); body["s3"] != "ok" {
t.Fatalf("expected s3 label ok, got %v", body)
}
srv.cfg.Storage.Backend = ""
if body := call(); body["s3"] != "ok" {
t.Fatalf("expected default s3 label, got %v", body)
}
}
-114
View File
@@ -1,114 +0,0 @@
package app
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
t.Helper()
gin.SetMode(gin.TestMode)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
fs, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("new fs: %v", err)
}
noop := func(context.Context) error { return nil }
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, store.Repos{}, noop, tm, fs, sse.NewHub(1))
return fs, srv.Router()
}
func TestServeObjectRoute(t *testing.T) {
ctx := context.Background()
fs, r := newTestServerWithFS(t)
key := "objects/abc/original.jpg"
if err := fs.Put(ctx, key, strings.NewReader("0123456789"), 10, "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
raw, err := fs.URL(ctx, key)
if err != nil {
t.Fatalf("url: %v", err)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse: %v", err)
}
do := func(target string, rangeHeader string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil)
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
t.Run("valid", func(t *testing.T) {
rec := do(u.Path+"?"+u.RawQuery, "")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.String() != "0123456789" {
t.Fatalf("body = %q", rec.Body.String())
}
})
t.Run("range", func(t *testing.T) {
rec := do(u.Path+"?"+u.RawQuery, "bytes=2-5")
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if rec.Body.String() != "2345" {
t.Fatalf("body = %q, want 2345", rec.Body.String())
}
})
t.Run("expired", func(t *testing.T) {
q := u.Query()
q.Set("e", strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10))
rec := do(u.Path+"?"+q.Encode(), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("forged", func(t *testing.T) {
q := u.Query()
q.Set("s", "deadbeef")
rec := do(u.Path+"?"+q.Encode(), "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
t.Run("missing", func(t *testing.T) {
raw, err := fs.URL(ctx, "objects/def/original.jpg")
if err != nil {
t.Fatalf("url: %v", err)
}
mu, _ := url.Parse(raw)
rec := do(mu.Path+"?"+mu.RawQuery, "")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
})
}
-213
View File
@@ -1,213 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func integrationPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func makeTestPNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
// runShareFlow uploads a photo and validates the read-only share flow returns a
// usable photo URL. verify inspects/serves the URL according to the backend.
func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.T, router *gin.Engine, raw string)) {
t.Helper()
ctx := context.Background()
pool := integrationPool(t)
gin.SetMode(gin.TestMode)
hub := sse.NewHub(1)
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub)
router := srv.Router()
authStore := auth.NewStore(pool)
owner := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Owner"}
if err := authStore.CreateUser(ctx, owner); err != nil {
t.Fatalf("create user: %v", err)
}
albumsStore := albums.NewStore(pool)
albumsSvc := albums.NewService(albumsStore, members.NewStore(pool), hub, backend)
album, err := albumsSvc.Create(ctx, owner.ID, albums.CreateInput{Title: "Share Album"})
if err != nil {
t.Fatalf("create album: %v", err)
}
photosSvc := photos.NewService(photos.NewStore(pool), albumsSvc, backend, hub)
pngData := makeTestPNG(t)
if _, err := photosSvc.Upload(ctx, photos.UploadInput{
AlbumID: album.ID, UserID: owner.ID, Filename: "pic.png", Data: pngData,
}); err != nil {
t.Fatalf("upload: %v", err)
}
var keys []string
rows, err := pool.Query(ctx,
`SELECT o.storage_key FROM photo_objects po
JOIN objects o ON o.id = po.object_id
JOIN photos p ON p.id = po.photo_id
WHERE p.album_id = $1`, album.ID)
if err == nil {
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keys = append(keys, k)
}
}
rows.Close()
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photos WHERE album_id = $1`, album.ID)
for _, k := range keys {
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE storage_key = $1`, k)
}
_ = backend.Delete(context.Background(), keys...)
_, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, album.ID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID)
})
shareSvc := sharelinks.NewService(sharelinks.NewStore(pool), albumsSvc)
link, err := shareSvc.Create(ctx, album.ID, owner.ID, time.Hour)
if err != nil {
t.Fatalf("share create: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/share/"+link.Token+"/photos", nil))
if rec.Code != http.StatusOK {
t.Fatalf("share photos status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Photos []models.Photo `json:"photos"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if len(body.Photos) != 1 {
t.Fatalf("photos len = %d, want 1", len(body.Photos))
}
if body.Photos[0].OriginalURL == "" {
t.Fatalf("empty original url")
}
verify(t, router, body.Photos[0].OriginalURL)
}
func TestShareFlowWithFS(t *testing.T) {
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
fs, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("new fs: %v", err)
}
runShareFlow(t, fs, func(t *testing.T, router *gin.Engine, raw string) {
if !strings.Contains(raw, "/api/objects/") {
t.Fatalf("expected fs signed url, got %q", raw)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil))
if rec.Code != http.StatusOK {
t.Fatalf("fs object fetch status = %d", rec.Code)
}
})
}
func TestShareFlowWithS3(t *testing.T) {
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Skipf("signer init: %v", err)
}
cl, err := storage.NewS3(context.Background(), config.S3{
Endpoint: endpoint,
Region: os.Getenv("S3_REGION"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Bucket: os.Getenv("S3_BUCKET"),
UsePathStyle: true,
}, signer)
if err != nil {
t.Skipf("storage init: %v", err)
}
runShareFlow(t, cl, func(t *testing.T, router *gin.Engine, raw string) {
if !strings.Contains(raw, "/api/objects/") {
t.Fatalf("expected backend url, got %q", raw)
}
if strings.Contains(raw, "X-Amz-Signature") {
t.Fatalf("presigned url leaked to client: %q", raw)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil))
if rec.Code != http.StatusOK {
t.Fatalf("s3 object fetch status = %d", rec.Code)
}
})
}
+1 -78
View File
@@ -1,9 +1,6 @@
package auth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
@@ -92,79 +89,5 @@ func (h *Handler) Me(c *gin.Context) {
httpx.RespondError(c, httpx.Unauthorized("unauthorized", "user not found"))
return
}
prefs := u.Preferences
if prefs == nil {
prefs = map[string]any{}
}
c.JSON(http.StatusOK, gin.H{"user": u.Public(), "preferences": prefs})
}
const (
maxVisibleAttrs = 500
maxLabelEntries = 500
maxPreferenceKeyLen = 128
maxPreferenceValueLen = 128
)
type photoInfoPreferences struct {
Visible []string `json:"visible"`
Labels map[string]string `json:"labels,omitempty"`
}
type preferencesPayload struct {
PhotoInfo *photoInfoPreferences `json:"photo_info,omitempty"`
}
func (p preferencesPayload) validate() error {
pi := p.PhotoInfo
if pi == nil {
return nil
}
if len(pi.Visible) > maxVisibleAttrs {
return fmt.Errorf("too many visible attributes")
}
for _, k := range pi.Visible {
if k == "" || len(k) > maxPreferenceKeyLen {
return fmt.Errorf("invalid visible attribute key")
}
}
if len(pi.Labels) > maxLabelEntries {
return fmt.Errorf("too many label overrides")
}
for k, v := range pi.Labels {
if k == "" || len(k) > maxPreferenceKeyLen {
return fmt.Errorf("invalid label key")
}
if len(v) > maxPreferenceValueLen {
return fmt.Errorf("label value too long")
}
}
return nil
}
func (h *Handler) UpdatePreferences(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not read request body"))
return
}
var payload preferencesPayload
if err := json.Unmarshal(body, &payload); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "preferences must be a JSON object"))
return
}
if err := payload.validate(); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", err.Error()))
return
}
raw, err := json.Marshal(payload)
if err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_preferences", "could not encode preferences"))
return
}
if err := h.svc.store.UpdatePreferences(c.Request.Context(), UserID(c), raw); err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"preferences": payload})
c.JSON(http.StatusOK, gin.H{"user": u.Public()})
}
-19
View File
@@ -1,19 +0,0 @@
package auth
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the auth domain.
type Repository interface {
CreateUser(ctx context.Context, u *models.User) error
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
GetUserByID(ctx context.Context, id string) (*models.User, error)
UpdatePreferences(ctx context.Context, userID string, raw []byte) error
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
RevokeRefreshToken(ctx context.Context, id string) error
RevokeAllUserTokens(ctx context.Context, userID string) error
}
+2 -2
View File
@@ -18,11 +18,11 @@ type TokenPair struct {
}
type Service struct {
store Repository
store *Store
tm *TokenManager
}
func NewService(store Repository, tm *TokenManager) *Service {
func NewService(store *Store, tm *TokenManager) *Service {
return &Service{store: store, tm: tm}
}
-111
View File
@@ -1,111 +0,0 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"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} }
func (s *SQLiteStore) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL))
return err
}
func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE lower(email) = lower($1)`, email))
}
func (s *SQLiteStore) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE id = $1`, id))
}
func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var prefs, createdAt string
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.CreatedAt = sqliteutil.ParseTime(createdAt)
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil
}
func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
res, err := s.db.ExecContext(ctx,
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
t.ID, t.UserID, t.TokenHash, sqliteutil.FormatTime(t.ExpiresAt))
return err
}
func (s *SQLiteStore) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
var t models.RefreshToken
var expiresAt, createdAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
FROM refresh_tokens WHERE token_hash = $1`, hash).
Scan(&t.ID, &t.UserID, &t.TokenHash, &expiresAt, &revokedAt, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
t.ExpiresAt = sqliteutil.ParseTime(expiresAt)
t.CreatedAt = sqliteutil.ParseTime(createdAt)
t.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &t, nil
}
func (s *SQLiteStore) RevokeRefreshToken(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`,
id, sqliteutil.FormatTime(time.Now()))
return err
}
func (s *SQLiteStore) RevokeAllUserTokens(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
userID, sqliteutil.FormatTime(time.Now()))
return err
}
+3 -22
View File
@@ -2,7 +2,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
@@ -30,45 +29,27 @@ func (s *Store) CreateUser(ctx context.Context, u *models.User) error {
func (s *Store) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'::jsonb)::text, created_at
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
FROM users WHERE lower(email) = lower($1)`, email))
}
func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.pool.QueryRow(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'::jsonb)::text, created_at
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), created_at
FROM users WHERE id = $1`, id))
}
func (s *Store) scanUser(row pgx.Row) (*models.User, error) {
var u models.User
var prefs string
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &u.CreatedAt); err != nil {
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.CreatedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil
}
func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
tag, err := s.pool.Exec(ctx,
`UPDATE users SET preferences = $2::jsonb WHERE id = $1`, userID, string(raw))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
+12 -73
View File
@@ -28,9 +28,12 @@ type S3 struct {
SecretKey string
Bucket string
UsePathStyle bool
PublicURL string
PresignTTL time.Duration
}
type Auth struct {
JWTSecret string
AccessTTL time.Duration
RefreshTTL time.Duration
}
@@ -45,33 +48,12 @@ type SSE struct {
ClientBuffer int
}
type Worker struct {
Interval time.Duration
Batch int
PendingTTL time.Duration
}
type Storage struct {
Backend string
FSRoot string
URLSecret string
URLTTL time.Duration
}
type Database struct {
Backend string
SQLitePath string
}
type Config struct {
Postgres Postgres
S3 S3
Storage Storage
Database Database
Auth Auth
Server Server
SSE SSE
Worker Worker
}
func Load() (*Config, error) {
@@ -90,9 +72,9 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
}
urlTTL, err := time.ParseDuration(getEnv("OBJECT_URL_TTL", "1h"))
presignTTL, err := time.ParseDuration(getEnv("S3_PRESIGN_TTL", "1h"))
if err != nil {
return nil, fmt.Errorf("invalid OBJECT_URL_TTL: %w", err)
return nil, fmt.Errorf("invalid S3_PRESIGN_TTL: %w", err)
}
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
@@ -110,21 +92,6 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
}
workerInterval, err := time.ParseDuration(getEnv("WORKER_INTERVAL", "5s"))
if err != nil {
return nil, fmt.Errorf("invalid WORKER_INTERVAL: %w", err)
}
workerBatch, err := strconv.Atoi(getEnv("WORKER_BATCH", "10"))
if err != nil {
return nil, fmt.Errorf("invalid WORKER_BATCH: %w", err)
}
workerPendingTTL, err := time.ParseDuration(getEnv("WORKER_PENDING_TTL", "10m"))
if err != nil {
return nil, fmt.Errorf("invalid WORKER_PENDING_TTL: %w", err)
}
cfg := &Config{
Postgres: Postgres{
Host: getEnv("PG_HOST", "pg.localnet"),
@@ -141,18 +108,11 @@ func Load() (*Config, error) {
SecretKey: getEnv("S3_SECRET_KEY", ""),
Bucket: getEnv("S3_BUCKET", "albums"),
UsePathStyle: pathStyle,
},
Storage: Storage{
Backend: getEnv("STORAGE_BACKEND", "fs"),
FSRoot: getEnv("FS_ROOT", "./data"),
URLSecret: getEnv("URL_SECRET", ""),
URLTTL: urlTTL,
},
Database: Database{
Backend: getEnv("DB_BACKEND", "sqlite"),
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
PublicURL: getEnv("S3_PUBLIC_URL", ""),
PresignTTL: presignTTL,
},
Auth: Auth{
JWTSecret: getEnv("JWT_SECRET", ""),
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
},
@@ -164,34 +124,13 @@ func Load() (*Config, error) {
HeartbeatInterval: heartbeat,
ClientBuffer: bufSize,
},
Worker: Worker{
Interval: workerInterval,
Batch: workerBatch,
PendingTTL: workerPendingTTL,
},
}
switch cfg.Database.Backend {
case "postgres":
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
}
case "sqlite":
if cfg.Database.SQLitePath == "" {
return nil, fmt.Errorf("SQLITE_PATH is required when DB_BACKEND=sqlite")
}
default:
return nil, fmt.Errorf("invalid DB_BACKEND %q (want postgres or sqlite)", cfg.Database.Backend)
if cfg.Auth.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
switch cfg.Storage.Backend {
case "s3":
case "fs":
if cfg.Storage.FSRoot == "" {
return nil, fmt.Errorf("FS_ROOT is required when STORAGE_BACKEND=fs")
}
default:
return nil, fmt.Errorf("invalid STORAGE_BACKEND %q (want s3 or fs)", cfg.Storage.Backend)
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
}
return cfg, nil
-134
View File
@@ -1,134 +0,0 @@
package config
import (
"testing"
"time"
)
func setRequiredEnv(t *testing.T) {
t.Helper()
t.Setenv("JWT_SECRET", "test-secret")
t.Setenv("PG_PASSWORD", "test-pass")
}
func TestLoadStorageBackend(t *testing.T) {
t.Run("defaults to fs", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "./data" {
t.Fatalf("storage = %+v", cfg.Storage)
}
})
t.Run("fs with root", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "fs")
t.Setenv("FS_ROOT", "/var/lib/galo/objects")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "/var/lib/galo/objects" {
t.Fatalf("storage = %+v", cfg.Storage)
}
})
t.Run("fs empty root falls back to default", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "fs")
t.Setenv("FS_ROOT", "")
t.Setenv("URL_SECRET", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.FSRoot != "./data" {
t.Fatalf("FSRoot = %q, want ./data", cfg.Storage.FSRoot)
}
})
t.Run("invalid backend", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "gcs")
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid backend")
}
})
t.Run("object url ttl", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "")
t.Setenv("OBJECT_URL_TTL", "30m")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.URLTTL != 30*time.Minute {
t.Fatalf("URLTTL = %v, want 30m", cfg.Storage.URLTTL)
}
})
}
func TestLoadDatabaseBackend(t *testing.T) {
t.Run("defaults to sqlite", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("DB_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("postgres requires password", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "postgres")
if _, err := Load(); err == nil {
t.Fatal("expected error for postgres without PG_PASSWORD")
}
})
t.Run("sqlite with path", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "/data/galo.db")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "/data/galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("sqlite empty path falls back to default", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("SQLitePath = %q, want ./galo.db", cfg.Database.SQLitePath)
}
})
t.Run("invalid backend", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "p")
t.Setenv("DB_BACKEND", "mysql")
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid backend")
}
})
}
-31
View File
@@ -8,10 +8,8 @@ import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
sqlitemig "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// Migrate applies all up migrations from the given fs (root contains *.sql files).
@@ -41,32 +39,3 @@ func Migrate(dsn string, migrations fs.FS) error {
}
return nil
}
// MigrateSQLite applies all up migrations from the given fs to a SQLite database
// at path (creating it if needed).
func MigrateSQLite(path string, migrations fs.FS) error {
source, err := iofs.New(migrations, ".")
if err != nil {
return fmt.Errorf("migrations source: %w", err)
}
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
driver, err := sqlitemig.WithInstance(sqlDB, &sqlitemig.Config{})
if err != nil {
return fmt.Errorf("migrate driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver)
if err != nil {
return fmt.Errorf("migrate instance: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
@@ -1,37 +0,0 @@
package db
import (
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
)
func TestMigrateSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate again: %v", err)
}
db, err := OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='objects'`).Scan(&n); err != nil {
t.Fatalf("query: %v", err)
}
if n != 1 {
t.Fatalf("objects table missing after migration")
}
}
-31
View File
@@ -1,31 +0,0 @@
package db
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// OpenSQLite opens (creating if needed) a SQLite database tuned for a single
// writer with concurrent readers: WAL journalling, a busy timeout so writers
// wait instead of failing, and enforced foreign keys.
func OpenSQLite(path string) (*sql.DB, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("sqlite path is required")
}
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return db, nil
}
-45
View File
@@ -1,45 +0,0 @@
package db
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestOpenSQLite(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("ping: %v", err)
}
var mode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("journal_mode: %v", err)
}
if !strings.EqualFold(mode, "wal") {
t.Fatalf("journal_mode = %q, want wal", mode)
}
var fk int
if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatalf("foreign_keys: %v", err)
}
if fk != 1 {
t.Fatalf("foreign_keys = %d, want 1", fk)
}
var busy int
if err := db.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil {
t.Fatalf("busy_timeout: %v", err)
}
if busy != 5000 {
t.Fatalf("busy_timeout = %d, want 5000", busy)
}
}
-198
View File
@@ -1,198 +0,0 @@
package e2e
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/png"
"io/fs"
"path/filepath"
"testing"
"time"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
type stack struct {
db *sql.DB
photos *photos.Service
albums *albums.Service
auth *auth.Service
}
// openStack opens a migrated SQLite database at path and builds the services.
func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
t.Helper()
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
repos := store.NewSQLite(database)
hub := sse.NewHub(1)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
blob, err := storage.NewFS(blobRoot, signer)
if err != nil {
t.Fatalf("fs: %v", err)
}
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc}, func() { database.Close() }
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute)
wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "e2e@test.local", "password123", "E2E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
waitFor(t, "variants of first photo", func() bool {
got, err := st.photos.Get(ctx, p.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
t.Fatalf("permanent delete: %v", err)
}
waitFor(t, "garbage collection", func() bool {
var n int
return st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects`).Scan(&n) == nil && n == 0
})
// Stop the worker, then queue another upload whose variant job must survive
// the restart and be processed by a fresh worker over the same file.
cancel()
time.Sleep(50 * time.Millisecond)
p2, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload p2: %v", err)
}
got2, _ := st.photos.Get(ctx, p2.ID, user.ID)
if got2.PreviewURL != "" {
t.Fatalf("variants generated before restart")
}
closeDB()
st2, close2 := openStack(t, path, blobs)
defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute)
wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2()
go worker2.Run(wctx2)
waitFor(t, "variants of second photo after restart", func() bool {
got, err := st2.photos.Get(ctx, p2.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
}
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
open := func() (*sql.DB, store.Repos) {
t.Helper()
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
return database, store.NewSQLite(database)
}
db1, repos1 := open()
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil || secret1 == "" {
t.Fatalf("ensure secret: %v %q", err, secret1)
}
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
token, err := tm1.GenerateAccess("user-1", "u@test.local")
if err != nil {
t.Fatalf("generate: %v", err)
}
_ = db1.Close()
db2, repos2 := open()
defer db2.Close()
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
if err != nil {
t.Fatalf("ensure after restart: %v", err)
}
if secret2 != secret1 {
t.Fatalf("secret changed across restart")
}
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
if _, err := tm2.ParseAccess(token); err != nil {
t.Fatalf("token issued before restart not valid after: %v", err)
}
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
-19
View File
@@ -1,19 +0,0 @@
package members
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the members domain.
type Repository interface {
Role(ctx context.Context, albumID, userID string) (string, error)
List(ctx context.Context, albumID string) ([]models.AlbumMember, error)
Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error
UpdateRole(ctx context.Context, albumID, userID, role string) error
Remove(ctx context.Context, albumID, userID string) error
CreateInvite(ctx context.Context, inv *models.Invite) error
GetInviteByToken(ctx context.Context, token string) (*models.Invite, error)
IncrementUses(ctx context.Context, id string) error
}
+2 -2
View File
@@ -21,13 +21,13 @@ type Notifier interface {
}
type Service struct {
store Repository
store *Store
albums AlbumReader
hub *sse.Hub
notify Notifier
}
func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, hub: hub, notify: notify}
}
-133
View File
@@ -1,133 +0,0 @@
package members
import (
"context"
"database/sql"
"errors"
"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} }
func (s *SQLiteStore) Role(ctx context.Context, albumID, userID string) (string, error) {
var role string
err := s.db.QueryRowContext(ctx,
`SELECT role FROM album_members WHERE album_id = $1 AND user_id = $2`,
albumID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.album_id, m.user_id, m.role, m.invited_by, m.joined_at,
u.id, u.email, u.name, COALESCE(u.avatar_url, '')
FROM album_members m
JOIN users u ON u.id = m.user_id
WHERE m.album_id = $1
ORDER BY m.joined_at`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.AlbumMember{}
for rows.Next() {
var m models.AlbumMember
var invitedBy sql.NullString
var joinedAt string
if err := rows.Scan(&m.AlbumID, &m.UserID, &m.Role, &invitedBy, &joinedAt,
&m.User.ID, &m.User.Email, &m.User.Name, &m.User.AvatarURL); err != nil {
return nil, err
}
if invitedBy.Valid {
m.InvitedBy = &invitedBy.String
}
m.JoinedAt = sqliteutil.ParseTime(joinedAt)
out = append(out, m)
}
return out, rows.Err()
}
func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (album_id, user_id) DO UPDATE SET role = excluded.role`,
albumID, userID, role, invitedBy)
return err
}
func (s *SQLiteStore) UpdateRole(ctx context.Context, albumID, userID, role string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE album_members SET role = $3 WHERE album_id = $1 AND user_id = $2`,
albumID, userID, role)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) Remove(ctx context.Context, albumID, userID string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM album_members WHERE album_id = $1 AND user_id = $2`, albumID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateInvite(ctx context.Context, inv *models.Invite) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO invites (id, album_id, token, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at`,
inv.ID, inv.AlbumID, inv.Token, inv.Role, inv.CreatedBy, sqliteutil.FormatTime(inv.ExpiresAt), inv.MaxUses,
).Scan(&createdAt)
if err != nil {
return err
}
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) GetInviteByToken(ctx context.Context, token string) (*models.Invite, error) {
var inv models.Invite
var expiresAt, createdAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, role, created_by, expires_at, max_uses, uses, created_at
FROM invites WHERE token = $1`, token).
Scan(&inv.ID, &inv.AlbumID, &inv.Token, &inv.Role, &inv.CreatedBy, &expiresAt, &inv.MaxUses, &inv.Uses, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
inv.ExpiresAt = sqliteutil.ParseTime(expiresAt)
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return &inv, nil
}
func (s *SQLiteStore) IncrementUses(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `UPDATE invites SET uses = uses + 1 WHERE id = $1`, id)
return err
}
+9 -21
View File
@@ -3,13 +3,12 @@ package models
import "time"
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
Preferences map[string]any `json:"preferences,omitempty"`
CreatedAt time.Time `json:"created_at"`
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type PublicUser struct {
@@ -33,9 +32,7 @@ type Album struct {
Description string `json:"description"`
CoverPhotoID *string `json:"cover_photo_id,omitempty"`
CoverThumbKey string `json:"-"`
CoverThumbURL string `json:"cover_thumb_url,omitempty"`
PhotosCount int `json:"photos_count"`
MembersCount int `json:"members_count"`
CoverURL string `json:"cover_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -76,12 +73,8 @@ type Photo struct {
TakenAt *time.Time `json:"taken_at,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
Caption string `json:"caption"`
EXIF map[string]any `json:"exif,omitempty"`
Tags []Tag `json:"tags,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
AlbumTitle string `json:"album_title,omitempty"`
CreatedAt time.Time `json:"created_at"`
Caption string `json:"caption"`
CreatedAt time.Time `json:"created_at"`
Uploader PublicUser `json:"uploader"`
LikesCount int `json:"likes_count"`
CommentsCount int `json:"comments_count"`
@@ -92,11 +85,6 @@ type Photo struct {
DownloadURL string `json:"download_url,omitempty"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Comment struct {
ID string `json:"id"`
PhotoID string `json:"photo_id"`
@@ -1,14 +0,0 @@
package notifications
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the notifications domain.
type Repository interface {
Create(ctx context.Context, n *models.Notification) error
List(ctx context.Context, userID string, limit int) ([]models.Notification, error)
MarkRead(ctx context.Context, id, userID string) error
}
+2 -2
View File
@@ -10,11 +10,11 @@ import (
)
type Service struct {
store Repository
store *Store
hub *sse.Hub
}
func NewService(store Repository, hub *sse.Hub) *Service {
func NewService(store *Store, hub *sse.Hub) *Service {
return &Service{store: store, hub: hub}
}
@@ -1,88 +0,0 @@
package notifications
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"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} }
func (s *SQLiteStore) Create(ctx context.Context, n *models.Notification) error {
payload, err := json.Marshal(n.PayloadJSON)
if err != nil {
return err
}
var createdAt string
err = s.db.QueryRowContext(ctx,
`INSERT INTO notifications (id, user_id, type, payload_json)
VALUES ($1, $2, $3, $4)
RETURNING created_at`,
n.ID, n.UserID, n.Type, string(payload)).Scan(&createdAt)
if err != nil {
return err
}
n.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, type, payload_json, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Notification{}
for rows.Next() {
var n models.Notification
var payload, createdAt string
var readAt sql.NullString
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &payload, &readAt, &createdAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payload), &n.PayloadJSON)
n.ReadAt = sqliteutil.ParseNullTime(readAt)
n.CreatedAt = sqliteutil.ParseTime(createdAt)
out = append(out, n)
}
return out, rows.Err()
}
func (s *SQLiteStore) MarkRead(ctx context.Context, id, userID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL`,
id, userID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT 1 FROM notifications WHERE id = $1 AND user_id = $2`, id, userID).Scan(&exists)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
}
return nil
}
-92
View File
@@ -1,92 +0,0 @@
package photos
import (
"context"
"testing"
"time"
)
func TestCopySharesStorageObjects(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
svc, cl := newTestService(t, pool)
user := createUser(t, ctx, pool)
albumA := createAlbum(t, ctx, pool, user)
albumB := createAlbum(t, ctx, pool, user)
p, err := svc.Upload(ctx, UploadInput{
AlbumID: albumA, UserID: user, Filename: "p.png", MimeType: "image/png", Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute)
w.processVariantJobs(ctx)
srcIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
if err != nil || len(srcIDs) != 3 {
t.Fatalf("source objects = %v err=%v", srcIDs, err)
}
clones, err := svc.CopyBatch(ctx, []string{p.ID}, user, albumB)
if err != nil || len(clones) != 1 {
t.Fatalf("copy: %v len=%d", err, len(clones))
}
clone := clones[0]
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, clone.ID) })
cloneIDs, err := svc.store.PhotoObjectIDs(ctx, clone.ID)
if err != nil || len(cloneIDs) != 3 {
t.Fatalf("clone objects = %v err=%v", cloneIDs, err)
}
if !sameSet(srcIDs, cloneIDs) {
t.Fatalf("clone does not share objects: src=%v clone=%v", srcIDs, cloneIDs)
}
for _, id := range cloneIDs {
if n, err := svc.store.ObjectRefCount(ctx, id); err != nil || n != 2 {
t.Fatalf("object %s ref count = %d err=%v, want 2", id, n, err)
}
}
// Move the source into the same album; the storage key and shared objects
// must be untouched.
beforeKey := p.StorageKey
moved, err := svc.MoveBatch(ctx, []string{p.ID}, user, albumB)
if err != nil || moved != 1 {
t.Fatalf("move: %v moved=%d", err, moved)
}
after, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get after move: %v", err)
}
if after.AlbumID != albumB {
t.Fatalf("photo not moved: album=%q", after.AlbumID)
}
if after.StorageKey != beforeKey {
t.Fatalf("move changed storage key: %q -> %q", beforeKey, after.StorageKey)
}
for _, id := range cloneIDs {
if n, err := svc.store.ObjectRefCount(ctx, id); err != nil || n != 2 {
t.Fatalf("object %s ref count after move = %d err=%v, want 2", id, n, err)
}
}
}
func sameSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
m := make(map[string]int, len(a))
for _, v := range a {
m[v]++
}
for _, v := range b {
m[v]--
if m[v] < 0 {
return false
}
}
return true
}
+2 -146
View File
@@ -1,7 +1,6 @@
package photos
import (
"errors"
"io"
"net/http"
"strconv"
@@ -13,7 +12,7 @@ import (
"shared-album/internal/httpx"
)
const maxUploadSize = 100 << 20
const maxUploadSize = 50 << 20
type Handler struct {
svc *Service
@@ -44,12 +43,7 @@ func (h *Handler) Upload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
form, err := c.MultipartForm()
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size (100 MB)"))
return
}
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
httpx.RespondError(c, httpx.BadRequest("invalid_request", "expected multipart form"))
return
}
files := form.File["files"]
@@ -99,24 +93,6 @@ func (h *Handler) Get(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"photo": p})
}
type renameRequest struct {
OriginalFilename string `json:"original_filename" binding:"required"`
}
func (h *Handler) Rename(c *gin.Context) {
var req renameRequest
if err := c.ShouldBindJSON(&req); err != nil {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "original_filename is required"))
return
}
p, err := h.svc.Rename(c.Request.Context(), c.Param("id"), auth.UserID(c), req.OriginalFilename)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Download(c *gin.Context) {
url, err := h.svc.Download(c.Request.Context(), c.Param("id"), auth.UserID(c))
if err != nil {
@@ -133,123 +109,3 @@ func (h *Handler) Delete(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
func (h *Handler) PermanentDelete(c *gin.Context) {
if err := h.svc.PermanentDelete(c.Request.Context(), c.Param("id"), auth.UserID(c)); err != nil {
httpx.RespondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) Restore(c *gin.Context) {
var req struct {
AlbumID string `json:"album_id"`
}
_ = c.ShouldBindJSON(&req)
p, err := h.svc.Restore(c.Request.Context(), c.Param("id"), auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) Trash(c *gin.Context) {
list, err := h.svc.Trash(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": list})
}
func (h *Handler) TrashCount(c *gin.Context) {
n, err := h.svc.TrashCount(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"count": n})
}
func (h *Handler) Tags(c *gin.Context) {
tags, err := h.svc.Tags(c.Request.Context(), auth.UserID(c), c.Query("q"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"tags": tags})
}
func (h *Handler) AddTag(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "name is required"))
return
}
p, err := h.svc.AddTag(c.Request.Context(), c.Param("id"), auth.UserID(c), req.Name)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
func (h *Handler) RemoveTag(c *gin.Context) {
p, err := h.svc.RemoveTag(c.Request.Context(), c.Param("id"), auth.UserID(c), c.Param("tag_id"))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photo": p})
}
type batchRequest struct {
IDs []string `json:"ids" binding:"required"`
AlbumID string `json:"album_id"`
}
func (h *Handler) DeleteBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids are required"))
return
}
n, err := h.svc.DeleteBatch(c.Request.Context(), req.IDs, auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
func (h *Handler) MoveBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
n, err := h.svc.MoveBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"moved": n})
}
func (h *Handler) CopyBatch(c *gin.Context) {
var req batchRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 || req.AlbumID == "" {
httpx.RespondError(c, httpx.BadRequest("invalid_request", "ids and album_id are required"))
return
}
created, err := h.svc.CopyBatch(c.Request.Context(), req.IDs, auth.UserID(c), req.AlbumID)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"photos": created})
}
-167
View File
@@ -1,167 +0,0 @@
package photos
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func testEnvOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
type stubAlbums struct{}
func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
return &models.Album{ID: albumID}, roles.Owner, nil
}
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 't')`,
id, id+"@test.local"); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
return id
}
func createAlbum(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID string) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO albums (id, owner_id, title) VALUES ($1, $2, 't')`, id, ownerID); err != nil {
t.Fatalf("create album: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id) })
return id
}
func createPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, uploaderID string) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, NULL, $2)`, id, uploaderID); err != nil {
t.Fatalf("create photo: %v", err)
}
return id
}
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
// newTestService builds a service backed by the real dev storage. It skips when
// S3 env is absent so unit runs stay hermetic.
func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
t.Helper()
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
if err != nil {
t.Skipf("signer init: %v", err)
}
cl, err := storage.NewS3(context.Background(), config.S3{
Endpoint: endpoint,
Region: testEnvOr("S3_REGION", "garage"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
}, signer)
if err != nil {
t.Skipf("storage init: %v", err)
}
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
}
// newTestServiceFS builds a service backed by a temporary local file-system
// storage backend so the full flow can run without external infrastructure.
func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) {
t.Helper()
signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour)
if err != nil {
t.Fatalf("storage init: %v", err)
}
fs, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("storage init: %v", err)
}
return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs
}
// cleanupPhoto removes a photo, its objects (rows and blobs) and bindings.
func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl storage.Backend, photoID string) {
t.Helper()
rows, err := pool.Query(ctx,
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
WHERE po.photo_id = $1`, photoID)
if err != nil {
return
}
var ids, keys []string
for rows.Next() {
var id, key string
if rows.Scan(&id, &key) == nil {
ids = append(ids, id)
keys = append(keys, key)
}
}
rows.Close()
_, _ = pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID)
for _, id := range ids {
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, id)
}
if cl != nil && len(keys) > 0 {
_ = cl.Delete(ctx, keys...)
}
}
-17
View File
@@ -1,17 +0,0 @@
package photos
import (
"fmt"
"strings"
)
// objectKey builds an opaque, album-free storage key for a physical object.
// The key never encodes album, photo or any other metadata entity; the mapping
// between a key and its photo binding lives only in the metadata layer. The
// role segment is cosmetic (for debugging) and is not authoritative.
func objectKey(objectID, role, ext string) string {
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("objects/%s/%s%s", objectID, role, ext)
}
-32
View File
@@ -1,32 +0,0 @@
package photos
import (
"strings"
"testing"
)
func TestObjectKeyIsAlbumFree(t *testing.T) {
cases := []struct {
name string
id string
role string
ext string
want string
}{
{"original with dot", "obj1", RoleOriginal, ".jpg", "objects/obj1/original.jpg"},
{"thumb without dot", "obj1", RoleThumb, "webp", "objects/obj1/thumb.webp"},
{"preview", "obj2", RolePreview, ".webp", "objects/obj2/preview.webp"},
{"empty ext", "obj3", RoleOriginal, "", "objects/obj3/original"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := objectKey(tc.id, tc.role, tc.ext)
if got != tc.want {
t.Fatalf("objectKey = %q, want %q", got, tc.want)
}
if strings.Contains(got, "album") {
t.Fatalf("key must not encode an album entity: %q", got)
}
})
}
}
-388
View File
@@ -1,388 +0,0 @@
package photos
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// Object lifecycle states. The DB row is the ledger entry that governs the
// lifecycle of the corresponding blob in the storage layer.
const (
ObjectPending = "pending"
ObjectReady = "ready"
ObjectDeleting = "deleting"
)
// Object roles as bound to a photo. The role is a property of the binding.
const (
RoleOriginal = "original"
RoleThumb = "thumb"
RolePreview = "preview"
)
var (
// ErrObjectNotFound is returned when an object row does not exist.
ErrObjectNotFound = errors.New("object not found")
// ErrObjectNotReady is returned when binding an object that is not ready.
ErrObjectNotReady = errors.New("object is not ready")
)
// Object is a physical file in the storage layer, described by metadata.
type Object struct {
ID string
StorageKey string
MimeType string
Size int64
Width int
Height int
Checksum string
State string
CreatedAt time.Time
}
// CreateObject inserts a new object row in the pending state.
func (s *Store) CreateObject(ctx context.Context, o *Object) error {
if o.State == "" {
o.State = ObjectPending
}
return s.pool.QueryRow(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(&o.CreatedAt)
}
// SetObjectState updates an object's lifecycle state.
func (s *Store) SetObjectState(ctx context.Context, id, state string) error {
tag, err := s.pool.Exec(ctx, `UPDATE objects SET state = $2 WHERE id = $1`, id, state)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrObjectNotFound
}
return nil
}
// ActivateObject marks an object ready after its bytes have been stored.
func (s *Store) ActivateObject(ctx context.Context, id string) error {
return s.SetObjectState(ctx, id, ObjectReady)
}
// MarkObjectDeleting marks an object as pending removal from storage.
func (s *Store) 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 *Store) DeleteObjectRow(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrObjectNotFound
}
return nil
}
// BindObject attaches a ready object to a photo under the given role. It locks
// the object row and refuses objects that are not ready, so an object being
// garbage-collected can never be bound.
func (s *Store) BindObject(ctx context.Context, photoID, objectID, role string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var state string
if err := tx.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1 FOR UPDATE`, objectID).Scan(&state); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrObjectNotFound
}
return err
}
if state != ObjectReady {
return ErrObjectNotReady
}
if _, err := tx.Exec(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(ctx)
}
// PhotoObjectIDs returns the object ids currently bound to a photo.
func (s *Store) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
rows, err := s.pool.Query(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 *Store) ObjectRefCount(ctx context.Context, objectID string) (int, error) {
var n int
err := s.pool.QueryRow(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 *Store) OriginalObject(ctx context.Context, photoID string) (*Object, error) {
var o Object
err := s.pool.QueryRow(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, &o.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrObjectNotFound
}
return nil, err
}
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 *Store) PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) {
rows, err := s.pool.Query(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. Rows are locked with SKIP LOCKED so
// concurrent workers do not contend and a concurrent bind cannot slip in.
func (s *Store) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx,
`SELECT o.id, o.storage_key FROM objects o
WHERE o.state = ANY($1)
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED`, []string{ObjectReady, ObjectDeleting}, limit)
if err != nil {
return nil, err
}
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()
if err := rows.Err(); err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit(ctx)
}
ids := make([]string, len(objs))
for i, o := range objs {
ids[i] = o.ID
}
if _, err := tx.Exec(ctx, `UPDATE objects SET state = $2 WHERE id = ANY($1)`, ids, ObjectDeleting); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return objs, nil
}
// ClaimStalePending marks abandoned pending objects (whose upload never
// completed) as deleting and returns them for cleanup.
func (s *Store) ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx,
`SELECT id, storage_key FROM objects
WHERE state = $1 AND created_at < $2
ORDER BY created_at
LIMIT $3
FOR UPDATE SKIP LOCKED`, ObjectPending, time.Now().Add(-olderThan), limit)
if err != nil {
return nil, err
}
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()
if err := rows.Err(); err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit(ctx)
}
ids := make([]string, len(objs))
for i, o := range objs {
ids[i] = o.ID
}
if _, err := tx.Exec(ctx, `UPDATE objects SET state = $2 WHERE id = ANY($1)`, ids, ObjectDeleting); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return objs, nil
}
// ClaimVariantJobs leases due variant jobs and returns their photo ids. The
// lease pushes next_attempt_at forward so the same job is not picked up twice
// while it is being processed.
func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
if limit <= 0 {
limit = 10
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx,
`SELECT photo_id FROM variant_jobs
WHERE next_attempt_at <= now()
ORDER BY next_attempt_at
LIMIT $1
FOR UPDATE SKIP LOCKED`, 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(ctx)
}
if _, err := tx.Exec(ctx,
`UPDATE variant_jobs SET next_attempt_at = $2 WHERE photo_id = ANY($1)`,
ids, time.Now().Add(lease)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return ids, nil
}
// CompleteVariantJob removes a finished job.
func (s *Store) CompleteVariantJob(ctx context.Context, photoID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID)
return err
}
// FailVariantJob records a failure and schedules a retry with exponential
// backoff. After maxAttempts the job is abandoned so a permanently broken photo
// cannot loop forever.
func (s *Store) FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var attempts int
err = tx.QueryRow(ctx,
`UPDATE variant_jobs
SET attempts = attempts + 1,
next_attempt_at = now() + make_interval(secs => least(300, 10 * power(2, attempts))),
last_error = $2
WHERE photo_id = $1
RETURNING attempts`, photoID, errMsg).Scan(&attempts)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return tx.Commit(ctx)
}
return err
}
if maxAttempts > 0 && attempts >= maxAttempts {
if _, err := tx.Exec(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID); err != nil {
return err
}
}
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()
}
-187
View File
@@ -1,187 +0,0 @@
package photos
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
)
func TestObjectStateTransitions(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg", MimeType: "image/jpeg", Size: 42}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) })
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if obj.State != ObjectPending {
t.Fatalf("new object state = %q, want %q", obj.State, ObjectPending)
}
if obj.CreatedAt.IsZero() {
t.Fatalf("created_at not populated")
}
if err := st.ActivateObject(ctx, objID); err != nil {
t.Fatalf("activate: %v", err)
}
if got := objectState(t, ctx, pool, objID); got != ObjectReady {
t.Fatalf("state after activate = %q", got)
}
if err := st.MarkObjectDeleting(ctx, objID); err != nil {
t.Fatalf("mark deleting: %v", err)
}
if got := objectState(t, ctx, pool, objID); got != ObjectDeleting {
t.Fatalf("state after mark = %q", got)
}
if err := st.DeleteObjectRow(ctx, objID); err != nil {
t.Fatalf("delete row: %v", err)
}
if err := st.DeleteObjectRow(ctx, objID); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("second delete = %v, want ErrObjectNotFound", err)
}
}
func TestClaimStalePending(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) })
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE objects SET created_at = now() - interval '1 hour' WHERE id = $1`, objID); err != nil {
t.Fatalf("age object: %v", err)
}
objs, err := st.ClaimStalePending(ctx, 10*time.Minute, 100)
if err != nil {
t.Fatalf("claim stale: %v", err)
}
if !containsObject(objs, objID) {
t.Fatalf("stale pending object not claimed: %+v", objs)
}
if got := objectState(t, ctx, pool, objID); got != ObjectDeleting {
t.Fatalf("claimed stale object state = %q, want deleting", got)
}
}
func TestBindObjectRequiresReadyAndShares(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
user := createUser(t, ctx, pool)
p1 := createPhoto(t, ctx, pool, user)
p2 := createPhoto(t, ctx, pool, user)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id = $1`, objID)
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID)
})
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if err := st.BindObject(ctx, p1, objID, RoleOriginal); !errors.Is(err, ErrObjectNotReady) {
t.Fatalf("bind pending object = %v, want ErrObjectNotReady", err)
}
if err := st.ActivateObject(ctx, objID); err != nil {
t.Fatalf("activate: %v", err)
}
if err := st.BindObject(ctx, p1, objID, RoleOriginal); err != nil {
t.Fatalf("bind p1: %v", err)
}
if err := st.BindObject(ctx, p2, objID, RoleOriginal); err != nil {
t.Fatalf("bind p2: %v", err)
}
if n, err := st.ObjectRefCount(ctx, objID); err != nil || n != 2 {
t.Fatalf("ref count = %d err=%v, want 2", n, err)
}
siblings, err := st.PhotosByOriginalObject(ctx, objID)
if err != nil || len(siblings) != 2 {
t.Fatalf("siblings = %v err=%v, want 2", siblings, err)
}
}
func TestClaimGarbageRespectsReferences(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
user := createUser(t, ctx, pool)
photo := createPhoto(t, ctx, pool, user)
boundID := auth.NewID()
freeID := auth.NewID()
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id IN ($1, $2)`, boundID, freeID)
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id IN ($1, $2)`, boundID, freeID)
})
for _, id := range []string{boundID, freeID} {
if err := st.CreateObject(ctx, &Object{ID: id, StorageKey: "objects/" + id + "/original.jpg"}); err != nil {
t.Fatalf("create object: %v", err)
}
if err := st.ActivateObject(ctx, id); err != nil {
t.Fatalf("activate: %v", err)
}
}
if err := st.BindObject(ctx, photo, boundID, RoleOriginal); err != nil {
t.Fatalf("bind: %v", err)
}
claimed, err := st.ClaimGarbage(ctx, 100)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
if !containsObject(claimed, freeID) {
t.Fatalf("unreferenced object not claimed: %+v", claimed)
}
if containsObject(claimed, boundID) {
t.Fatalf("referenced object was claimed for deletion")
}
// Removing the last reference makes both collectible.
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo); err != nil {
t.Fatalf("delete photo: %v", err)
}
claimed, err = st.ClaimGarbage(ctx, 100)
if err != nil {
t.Fatalf("claim garbage 2: %v", err)
}
if !containsObject(claimed, boundID) {
t.Fatalf("object with no remaining references not claimed: %+v", claimed)
}
}
func objectState(t *testing.T, ctx context.Context, pool *pgxpool.Pool, id string) string {
t.Helper()
var state string
if err := pool.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1`, id).Scan(&state); err != nil {
t.Fatalf("read state: %v", err)
}
return state
}
func containsObject(objs []Object, id string) bool {
for _, o := range objs {
if o.ID == id {
return true
}
}
return false
}
+13 -92
View File
@@ -2,18 +2,12 @@ package photos
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"strconv"
"strings"
"time"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
"github.com/rwcarlsen/goexif/exif"
)
type ImageMeta struct {
@@ -35,90 +29,23 @@ func inspect(data []byte) (width, height int, ok bool) {
return cfg.Width, cfg.Height, true
}
type exifInfo struct {
TakenAt *time.Time
Latitude *float64
Longitude *float64
Attributes map[string]any
}
func collectTags(ifd *exif.Ifd, dst map[string]any) {
if ifd == nil {
return
}
for _, ite := range ifd.Entries() {
name := ite.TagName()
if name == "" || strings.HasPrefix(name, "UnknownTag") {
continue
}
if _, exists := dst[name]; exists {
continue
}
s, err := ite.FormatFirst()
if err != nil {
continue
}
s = strings.TrimSpace(strings.TrimRight(s, "\x00"))
if s == "" {
continue
}
dst[name] = normalizeExifValue(s)
}
for _, child := range ifd.Children() {
collectTags(child, dst)
}
}
func normalizeExifValue(s string) any {
base := strings.TrimSuffix(s, "...")
if i := strings.IndexByte(base, '/'); i > 0 {
num, err1 := strconv.ParseInt(strings.TrimSpace(base[:i]), 10, 64)
den, err2 := strconv.ParseInt(strings.TrimSpace(base[i+1:]), 10, 64)
if err1 == nil && err2 == nil && den != 0 {
return float64(num) / float64(den)
}
}
if n, err := strconv.ParseInt(base, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(base, 64); err == nil {
return f
}
return s
}
func extractEXIF(data []byte) exifInfo {
out := exifInfo{Attributes: map[string]any{}}
raw, err := exif.SearchAndExtractExif(data)
func extractEXIF(data []byte) (taken *time.Time, lat, lon *float64) {
x, err := exif.Decode(bytes.NewReader(data))
if err != nil {
return out
return nil, nil, nil
}
im, err := exifcommon.NewIfdMappingWithStandard()
if err != nil {
return out
}
ti := exif.NewTagIndex()
_, index, err := exif.Collect(im, ti, raw)
if err != nil || index.RootIfd == nil {
return out
}
collectTags(index.RootIfd, out.Attributes)
if v, ok := out.Attributes["DateTimeOriginal"]; ok {
if t, err := time.Parse("2006:01:02 15:04:05", fmt.Sprintf("%v", v)); err == nil {
utc := t.UTC()
out.TakenAt = &utc
if dt, err := x.Get(exif.DateTimeOriginal); err == nil {
if s, err := dt.StringVal(); err == nil {
if t, err := time.Parse("2006:01:02 15:04:05", s); err == nil {
utc := t.UTC()
taken = &utc
}
}
}
if gi, err := index.RootIfd.GpsInfo(); err == nil {
lat := gi.Latitude.Decimal()
lon := gi.Longitude.Decimal()
out.Latitude, out.Longitude = &lat, &lon
if la, lo, err := x.LatLong(); err == nil {
lat, lon = &la, &lo
}
return out
}
func ExtractEXIFAttributes(data []byte) map[string]any {
return extractEXIF(data).Attributes
return taken, lat, lon
}
func buildVariants(data []byte) (thumb, preview []byte, err error) {
@@ -140,9 +67,3 @@ func buildVariants(data []byte) (thumb, preview []byte, err error) {
}
return thumbBuf.Bytes(), previewBuf.Bytes(), nil
}
// checksum returns the hex-encoded SHA-256 of the given bytes.
func checksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
-52
View File
@@ -1,52 +0,0 @@
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)
}
+64 -354
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
@@ -22,13 +23,13 @@ type AlbumAccess interface {
}
type Service struct {
store Repository
store *Store
albums AlbumAccess
storage storage.Backend
storage *storage.Client
hub *sse.Hub
}
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service {
return &Service{store: store, albums: albums, storage: storage, hub: hub}
}
@@ -61,54 +62,39 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image")
}
meta := extractEXIF(in.Data)
taken, lat, lon := extractEXIF(in.Data)
id := auth.NewID()
ext := strings.ToLower(filepath.Ext(in.Filename))
if ext == "" {
ext = ".bin"
}
storageKey := fmt.Sprintf("albums/%s/original/%s%s", in.AlbumID, id, ext)
photoID := auth.NewID()
objectID := auth.NewID()
key := objectKey(objectID, RoleOriginal, ext)
if err := s.storage.Put(ctx, storageKey, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
return nil, err
}
p := &models.Photo{
ID: photoID,
ID: id,
AlbumID: in.AlbumID,
UploaderID: in.UserID,
StorageKey: key,
StorageKey: storageKey,
OriginalFilename: in.Filename,
Size: int64(len(in.Data)),
Width: width,
Height: height,
MimeType: mime,
TakenAt: meta.TakenAt,
Latitude: meta.Latitude,
Longitude: meta.Longitude,
EXIF: meta.Attributes,
TakenAt: taken,
Latitude: lat,
Longitude: lon,
}
obj := &Object{
ID: objectID,
StorageKey: key,
MimeType: mime,
Size: int64(len(in.Data)),
Width: width,
Height: height,
Checksum: checksum(in.Data),
if err := s.store.Create(ctx, p); err != nil {
return nil, err
}
// The metadata layer is the ledger of record: the row is written first,
// then the bytes are stored, then the object is activated.
if err := s.store.CreateWithOriginal(ctx, p, obj); err != nil {
return nil, err
}
if err := s.storage.Put(ctx, key, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
_ = s.store.DeleteUpload(ctx, photoID, objectID)
return nil, err
}
if err := s.store.ActivateObject(ctx, objectID); err != nil {
return nil, err
}
data := in.Data
go s.processVariants(p.ID, in.AlbumID, data)
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
@@ -116,9 +102,36 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
return p, nil
}
func (s *Service) processVariants(photoID, albumID string, data []byte) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
thumb, preview, err := buildVariants(data)
if err != nil {
return
}
thumbKey := fmt.Sprintf("albums/%s/thumb/%s.webp", albumID, photoID)
previewKey := fmt.Sprintf("albums/%s/preview/%s.webp", albumID, photoID)
if err := s.storage.Put(ctx, thumbKey, bytes.NewReader(thumb), int64(len(thumb)), "image/webp"); err != nil {
return
}
if err := s.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/webp"); err != nil {
return
}
if err := s.store.SetVariants(ctx, photoID, thumbKey, previewKey); err != nil {
return
}
s.store.SetCover(ctx, albumID, photoID)
if p, err := s.store.GetByID(ctx, photoID, ""); err == nil {
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(albumID, "photo.created", p)
}
}
}
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
if p.StorageKey != "" {
url, err := s.storage.URL(ctx, p.StorageKey)
url, err := s.storage.PresignGet(ctx, p.StorageKey)
if err != nil {
return err
}
@@ -126,12 +139,12 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
p.DownloadURL = url
}
if p.ThumbKey != "" {
if url, err := s.storage.URL(ctx, p.ThumbKey); err == nil {
if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil {
p.ThumbURL = url
}
}
if p.PreviewKey != "" {
if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil {
if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil {
p.PreviewURL = url
}
}
@@ -193,22 +206,19 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
return p, nil
}
// DownloadPublic returns a signed original URL for a share link.
// DownloadPublic returns a presigned original URL for a share link.
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
p, err := s.GetPublic(ctx, photoID)
if err != nil {
return "", err
}
return s.storage.URL(ctx, p.StorageKey)
return s.storage.PresignGet(ctx, p.StorageKey)
}
func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Photo, error) {
@@ -219,9 +229,6 @@ func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Phot
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
@@ -236,47 +243,13 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string,
if err != nil {
return "", err
}
url, err := s.storage.URL(ctx, p.StorageKey)
url, err := s.storage.PresignGet(ctx, p.StorageKey)
if err != nil {
return "", err
}
return url, nil
}
func (s *Service) Rename(ctx context.Context, photoID, userID, filename string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
own := p.UploaderID == userID
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return nil, httpx.Forbidden("forbidden", "you cannot rename this photo")
}
name := strings.TrimSpace(filename)
if name == "" {
return nil, httpx.BadRequest("invalid_filename", "filename is required")
}
if len(name) > 255 {
name = name[:255]
}
if err := s.store.UpdateFilename(ctx, photoID, name); err != nil {
return nil, err
}
p.OriginalFilename = name
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
s.hub.Publish(p.AlbumID, "photo.updated", p)
return p, nil
}
func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
@@ -289,287 +262,24 @@ func (s *Service) Delete(ctx context.Context, photoID, userID string) error {
if err != nil {
return err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
own := p.UploaderID == userID
if !(roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))) {
return httpx.Forbidden("forbidden", "you cannot delete this photo")
}
if p.DeletedAt != nil {
return nil
}
if err := s.store.SetDeleted(ctx, photoID, true); err != nil {
if err := s.store.Delete(ctx, photoID); err != nil {
return err
}
s.publishDeleted(p)
return nil
}
func canDeletePhoto(role string, own bool) bool {
return roles.CanDeleteAnyPhoto(role) || (own && roles.CanDeleteOwnPhoto(role))
}
func (s *Service) publishDeleted(p *models.Photo) {
s.store.ClearCover(ctx, p.AlbumID, p.ID)
keys := []string{p.StorageKey}
if p.ThumbKey != "" {
keys = append(keys, p.ThumbKey)
}
if p.PreviewKey != "" {
keys = append(keys, p.PreviewKey)
}
_ = s.storage.Delete(ctx, keys...)
s.hub.Publish(p.AlbumID, "photo.deleted", map[string]string{
"id": p.ID, "album_id": p.AlbumID, "uploader_id": p.UploaderID,
})
}
// DeleteBatch moves the given photos to the trash. It skips photos the user cannot delete.
func (s *Service) DeleteBatch(ctx context.Context, ids []string, userID string) (int, error) {
deleted := 0
for _, id := range ids {
if err := s.Delete(ctx, id, userID); err != nil {
continue
}
deleted++
}
return deleted, nil
}
// Restore returns a photo from the trash back to its album.
// Restore returns a photo from the trash back to an album. When targetAlbumID is
// empty the photo goes back to its own album; if that album is gone, an
// album_required error is returned so the client can prompt for a target.
func (s *Service) Restore(ctx context.Context, photoID, userID, targetAlbumID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if targetAlbumID != "" {
_, role, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(role) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
if err := s.store.RestoreToAlbum(ctx, photoID, targetAlbumID); err != nil {
return nil, err
}
p.AlbumID = targetAlbumID
p.DeletedAt = nil
} else {
if p.AlbumID == "" {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
exists, err := s.store.AlbumExists(ctx, p.AlbumID)
if err != nil {
return nil, err
}
if !exists {
return nil, httpx.BadRequest("album_required", "choose an album to restore this photo into")
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return nil, err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return nil, httpx.Forbidden("forbidden", "you cannot restore this photo")
}
if p.DeletedAt != nil {
if err := s.store.SetDeleted(ctx, photoID, false); err != nil {
return nil, err
}
}
p.DeletedAt = nil
}
if err := s.attachURLs(ctx, p); err != nil {
return nil, err
}
if p.AlbumID != "" {
s.hub.Publish(p.AlbumID, "photo.created", p)
}
return p, nil
}
// PermanentDelete removes the photo row; stored objects are deleted only when no
// other album references the same storage key.
func (s *Service) PermanentDelete(ctx context.Context, photoID, userID string) error {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return httpx.NotFound("photo_not_found", "photo not found")
}
return err
}
_, role, err := s.albums.Access(ctx, p.AlbumID, userID)
if err != nil {
return err
}
if !canDeletePhoto(role, p.UploaderID == userID) {
return httpx.Forbidden("forbidden", "you cannot delete this photo")
}
objectIDs, err := s.store.PhotoObjectIDs(ctx, photoID)
if err != nil {
return err
}
if err := s.store.HardDelete(ctx, photoID); err != nil {
return err
}
if p.AlbumID != "" {
s.publishDeleted(p)
}
// Mark objects that no longer have any binding for garbage collection.
// The background worker removes the blobs from storage.
for _, oid := range objectIDs {
if n, err := s.store.ObjectRefCount(ctx, oid); err == nil && n == 0 {
_ = s.store.MarkObjectDeleting(ctx, oid)
}
}
return nil
}
// MoveBatch moves photos to another album (metadata only; stored objects untouched).
func (s *Service) MoveBatch(ctx context.Context, ids []string, userID, targetAlbumID string) (int, error) {
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return 0, err
}
if !roles.CanUpload(targetRole) {
return 0, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
moved := 0
for _, id := range ids {
p, err := s.store.GetByID(ctx, id, userID)
if err != nil || p.DeletedAt != nil {
continue
}
oldAlbum := p.AlbumID
if oldAlbum == targetAlbumID {
continue
}
_, srcRole, err := s.albums.Access(ctx, oldAlbum, userID)
if err != nil {
continue
}
if !canDeletePhoto(srcRole, p.UploaderID == userID) {
continue
}
if err := s.store.UpdateAlbum(ctx, id, targetAlbumID); err != nil {
continue
}
p.AlbumID = targetAlbumID
s.hub.Publish(oldAlbum, "photo.deleted", map[string]string{
"id": p.ID, "album_id": oldAlbum, "uploader_id": p.UploaderID,
})
if err := s.attachURLs(ctx, p); err == nil {
s.hub.Publish(targetAlbumID, "photo.created", p)
}
moved++
}
return moved, nil
}
// CopyBatch clones photos into another album (metadata only; stored objects shared).
func (s *Service) CopyBatch(ctx context.Context, ids []string, userID, targetAlbumID string) ([]models.Photo, error) {
_, targetRole, err := s.albums.Access(ctx, targetAlbumID, userID)
if err != nil {
return nil, err
}
if !roles.CanUpload(targetRole) {
return nil, httpx.Forbidden("forbidden", "you cannot add photos to the target album")
}
out := []models.Photo{}
for _, id := range ids {
src, err := s.store.GetByID(ctx, id, userID)
if err != nil || src.DeletedAt != nil {
continue
}
if _, _, err := s.albums.Access(ctx, src.AlbumID, userID); err != nil {
continue
}
newID := auth.NewID()
if err := s.store.Clone(ctx, src, targetAlbumID, userID, newID); err != nil {
continue
}
clone, err := s.store.GetByID(ctx, newID, userID)
if err != nil {
continue
}
if err := s.attachURLs(ctx, clone); err != nil {
continue
}
s.store.SetCoverIfEmpty(ctx, targetAlbumID, newID)
s.hub.Publish(targetAlbumID, "photo.created", clone)
out = append(out, *clone)
}
return out, nil
}
// Trash lists soft-deleted photos from all albums the user belongs to.
func (s *Service) Trash(ctx context.Context, userID string) ([]models.Photo, error) {
out, err := s.store.ListTrash(ctx, userID)
if err != nil {
return nil, err
}
for i := range out {
_ = s.attachURLs(ctx, &out[i])
}
return out, nil
}
// TrashCount returns the number of photos in the user's trash.
func (s *Service) TrashCount(ctx context.Context, userID string) (int, error) {
return s.store.TrashCount(ctx, userID)
}
// Tags lists the user's tags, optionally filtered by a search query.
func (s *Service) Tags(ctx context.Context, userID, q string) ([]models.Tag, error) {
return s.store.ListTags(ctx, userID, q)
}
func (s *Service) taggablePhoto(ctx context.Context, photoID, userID string) (*models.Photo, error) {
p, err := s.store.GetByID(ctx, photoID, userID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
return nil, err
}
if p.DeletedAt != nil {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if p.AlbumID == "" {
return nil, httpx.NotFound("photo_not_found", "photo not found")
}
if _, _, err := s.albums.Access(ctx, p.AlbumID, userID); err != nil {
return nil, err
}
return p, nil
}
// AddTag attaches a tag (created on demand) to a photo and returns the refreshed photo.
func (s *Service) AddTag(ctx context.Context, photoID, userID, name string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
tag, err := s.store.EnsureTag(ctx, userID, name)
if err != nil {
return nil, httpx.BadRequest("invalid_tag", "tag name is required")
}
if err := s.store.AttachTag(ctx, photoID, tag.ID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
// RemoveTag detaches one of the user's tags from a photo.
func (s *Service) RemoveTag(ctx context.Context, photoID, userID, tagID string) (*models.Photo, error) {
if _, err := s.taggablePhoto(ctx, photoID, userID); err != nil {
return nil, err
}
owned, err := s.store.TagOwnedBy(ctx, tagID, userID)
if err != nil {
return nil, err
}
if !owned {
return nil, httpx.NotFound("tag_not_found", "tag not found")
}
if err := s.store.DetachTag(ctx, photoID, tagID); err != nil {
return nil, err
}
return s.Get(ctx, photoID, userID)
}
-30
View File
@@ -1,30 +0,0 @@
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() }
-365
View File
@@ -1,365 +0,0 @@
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()
}
-470
View File
@@ -1,470 +0,0 @@
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
}
+31 -390
View File
@@ -2,16 +2,12 @@ package photos
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/models"
)
@@ -23,134 +19,53 @@ type Store struct {
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
// photoColumns selects photo rows together with the keys and physical facts of
// their bound objects (original, thumb, preview) and the original's size,
// dimensions and mime type.
const photoColumns = `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, ''),
const photoColumns = `p.id, p.album_id, p.uploader_id, p.storage_key,
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
p.original_filename, p.size, p.width, p.height, p.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, '{}'::jsonb)::text, p.deleted_at,
COALESCE((SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY lower(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), '[]')::text`
const photoJoins = `
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`
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2)`
func scanPhoto(row pgx.Row) (*models.Photo, error) {
var p models.Photo
var exifText, tagsText string
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
&p.LikesCount, &p.CommentsCount, &p.LikedByMe)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil
}
func setTags(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "[]" {
return
}
var tags []models.Tag
if err := json.Unmarshal([]byte(raw), &tags); err == nil && len(tags) > 0 {
p.Tags = tags
}
}
func setExif(p *models.Photo, raw string) {
if raw == "" || raw == "null" || raw == "{}" {
return
}
var m map[string]any
if err := json.Unmarshal([]byte(raw), &m); err == nil && len(m) > 0 {
p.EXIF = m
}
}
// CreateWithOriginal records a new photo together with its pending original
// object, the original binding and a variant job — all in one transaction. The
// caller stores the bytes and then activates the object. The metadata layer is
// the ledger of record; the storage layer follows.
func (s *Store) 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.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(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
}
if err := tx.QueryRow(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::jsonb)
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
return s.pool.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
size, width, height, mime_type, taken_at, latitude, longitude, caption)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING created_at`,
p.ID, p.AlbumID, p.UploaderID, p.OriginalFilename, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
).Scan(&p.CreatedAt); err != nil {
return err
}
if _, err := tx.Exec(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.Exec(ctx,
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
return err
}
return tx.Commit(ctx)
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption,
).Scan(&p.CreatedAt)
}
// DeleteUpload rolls back a failed upload: the photo (and its bindings and job)
// plus the pending object row are removed.
func (s *Store) DeleteUpload(ctx context.Context, photoID, objectID string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
return err
}
return tx.Commit(ctx)
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
_, err := s.pool.Exec(ctx,
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
return err
}
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
return scanPhoto(s.pool.QueryRow(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
FROM photos p JOIN users u ON u.id = p.uploader_id
WHERE p.id = $1`, id, viewerID))
}
@@ -168,9 +83,8 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
}
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
FROM photos p JOIN users u ON u.id = p.uploader_id
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::timestamptz IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
@@ -184,84 +98,20 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText, tagsText string
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,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
&p.LikesCount, &p.CommentsCount, &p.LikedByMe); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) SetDeleted(ctx context.Context, id string, deleted bool) error {
var tag pgconn.CommandTag
var err error
if deleted {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, id)
} else {
tag, err = s.pool.Exec(ctx,
`UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
}
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Clone creates a new photo row in albumID that references the same objects as
// src. No storage operation is performed: the clone shares the physical files.
func (s *Store) 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.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(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::jsonb)`,
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil {
return err
}
if _, err := tx.Exec(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(ctx)
}
// HardDelete permanently removes the photo row. Bound objects are not touched
// here; the caller marks orphaned objects for collection.
func (s *Store) HardDelete(ctx context.Context, id string) error {
func (s *Store) Delete(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
@@ -272,226 +122,17 @@ func (s *Store) HardDelete(ctx context.Context, id string) error {
return nil
}
// ListTrash returns soft-deleted photos from albums the user belongs to, plus
// their own photos whose album no longer exists.
func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.pool.Query(ctx,
`SELECT `+photoColumns+`, 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`+photoJoins+`
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 string
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,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
return nil, err
}
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
func (s *Store) SetCover(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, albumID, photoID)
}
// TrashCount counts soft-deleted photos accessible to the user.
func (s *Store) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
func (s *Store) ClearCover(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx, `UPDATE albums SET cover_photo_id = NULL WHERE id = $1 AND cover_photo_id = $2`, albumID, photoID)
}
func (s *Store) PhotoInAlbum(ctx context.Context, photoID, albumID string) (bool, error) {
var exists bool
err := s.pool.QueryRow(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
}
// RestoreToAlbum reassigns a soft-deleted photo to an album and clears the trash flag.
func (s *Store) RestoreToAlbum(ctx context.Context, id, albumID string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AlbumExists reports whether an album row exists.
func (s *Store) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok, err
}
func (s *Store) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name ILIKE '%' || $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()
}
// EnsureTag returns the user's tag with the given name, creating it if needed.
func (s *Store) 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.pool.QueryRow(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, pgx.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.pool.QueryRow(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.pool.QueryRow(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 *Store) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *Store) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok bool
err := s.pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok, err
}
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.pool.Exec(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
tag, err := s.pool.Exec(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type backfillRow struct {
ID string
StorageKey string
}
// BackfillEXIF re-reads the original object of every photo with empty exif and
// fills the exif/geo fields on the photo.
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.pool.Query(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 = '{}'::jsonb
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("{}")
}
_, err = s.pool.Exec(ctx,
`UPDATE photos SET exif = $2::jsonb,
taken_at = COALESCE(taken_at, $3),
latitude = COALESCE(latitude, $4),
longitude = COALESCE(longitude, $5)
WHERE id = $1`,
r.ID, string(attrs), meta.TakenAt, meta.Latitude, meta.Longitude)
if err != nil {
continue
}
updated++
}
return updated, nil
`SELECT EXISTS(SELECT 1 FROM photos WHERE id = $1 AND album_id = $2)`, photoID, albumID).Scan(&exists)
return exists, err
}
-70
View File
@@ -1,70 +0,0 @@
package photos
import (
"context"
"strings"
"testing"
)
func TestUploadCreatesLedgerAndAlbumFreeKey(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
svc, cl := newTestService(t, pool)
user := createUser(t, ctx, pool)
album := createAlbum(t, ctx, pool, user)
p, err := svc.Upload(ctx, UploadInput{
AlbumID: album,
UserID: user,
Filename: "photo.png",
MimeType: "image/png",
Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
if !strings.HasPrefix(p.StorageKey, "objects/") {
t.Fatalf("storage key %q is not an object key", p.StorageKey)
}
if strings.Contains(p.StorageKey, album) {
t.Fatalf("storage key leaks album id: %q", p.StorageKey)
}
if p.OriginalURL == "" {
t.Fatalf("original url not set")
}
if p.ThumbURL != "" || p.PreviewURL != "" {
t.Fatalf("variants should be pending, got thumb=%q preview=%q", p.ThumbURL, p.PreviewURL)
}
orig, err := svc.store.OriginalObject(ctx, p.ID)
if err != nil {
t.Fatalf("original object: %v", err)
}
if orig.State != ObjectReady {
t.Fatalf("original object state = %q, want ready", orig.State)
}
if n, err := svc.store.ObjectRefCount(ctx, orig.ID); err != nil || n != 1 {
t.Fatalf("original ref count = %d err=%v, want 1", n, err)
}
var jobs int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM variant_jobs WHERE photo_id = $1`, p.ID).Scan(&jobs); err != nil {
t.Fatalf("count jobs: %v", err)
}
if jobs != 1 {
t.Fatalf("variant jobs = %d, want 1", jobs)
}
list, _, err := svc.List(ctx, album, user, "", nil, 50)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if list[0].MimeType != "image/png" || list[0].Width != 4 || list[0].Height != 4 {
t.Fatalf("derived fields wrong: mime=%q %dx%d", list[0].MimeType, list[0].Width, list[0].Height)
}
if list[0].OriginalURL == "" {
t.Fatalf("list original url not set")
}
}
-210
View File
@@ -1,210 +0,0 @@
package photos
import (
"bytes"
"context"
"errors"
"log/slog"
"time"
"shared-album/internal/auth"
)
const maxVariantAttempts = 5
// Worker is the background reconciler. It drives the variant generation queue
// and the object garbage collector entirely from the state recorded in the
// metadata layer, so it resumes correctly after a restart.
type Worker struct {
svc *Service
interval time.Duration
batch int
pendingTTL time.Duration
lease time.Duration
}
// NewWorker builds a worker. Parameters are clamped to sane defaults.
func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.Duration) *Worker {
if interval <= 0 {
interval = 5 * time.Second
}
if batch <= 0 {
batch = 10
}
if pendingTTL <= 0 {
pendingTTL = 10 * time.Minute
}
return &Worker{
svc: svc,
interval: interval,
batch: batch,
pendingTTL: pendingTTL,
lease: interval * 4,
}
}
// Run loops until the context is cancelled.
func (w *Worker) Run(ctx context.Context) {
slog.Info("background worker started", "interval", w.interval)
defer slog.Info("background worker stopped")
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
w.tick(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *Worker) tick(ctx context.Context) {
w.processVariantJobs(ctx)
w.collectGarbage(ctx)
w.reapStalePending(ctx)
}
func (w *Worker) processVariantJobs(ctx context.Context) {
ids, err := w.svc.store.ClaimVariantJobs(ctx, w.batch, w.lease)
if err != nil {
slog.Error("claim variant jobs", "error", err)
return
}
for _, id := range ids {
w.generateVariants(ctx, id)
}
}
func (w *Worker) generateVariants(ctx context.Context, photoID string) {
orig, err := w.svc.store.OriginalObject(ctx, photoID)
if err != nil {
// The photo or its original binding is gone; nothing to do.
_ = w.svc.store.CompleteVariantJob(ctx, photoID)
return
}
if orig.State != ObjectReady {
w.fail(ctx, photoID, "original object is not ready")
return
}
data, err := w.svc.storage.Get(ctx, orig.StorageKey)
if err != nil {
w.fail(ctx, photoID, "fetch original: "+err.Error())
return
}
thumb, preview, err := buildVariants(data)
if err != nil {
w.fail(ctx, photoID, "build variants: "+err.Error())
return
}
siblings, err := w.svc.store.PhotosByOriginalObject(ctx, orig.ID)
if err != nil || len(siblings) == 0 {
siblings = []string{photoID}
}
if err := w.createVariant(ctx, siblings, RoleThumb, thumb); err != nil {
w.fail(ctx, photoID, "store thumb: "+err.Error())
return
}
if err := w.createVariant(ctx, siblings, RolePreview, preview); err != nil {
w.fail(ctx, photoID, "store preview: "+err.Error())
return
}
_ = w.svc.store.CompleteVariantJob(ctx, photoID)
for _, pid := range siblings {
w.notify(ctx, pid)
}
}
// createVariant stores one derived object and binds it to every photo sharing
// the original, so copies reuse the same physical variant.
func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role string, data []byte) error {
objID := auth.NewID()
key := objectKey(objID, role, ".webp")
obj := &Object{
ID: objID,
StorageKey: key,
MimeType: "image/webp",
Size: int64(len(data)),
Checksum: checksum(data),
}
if err := w.svc.store.CreateObject(ctx, obj); err != nil {
return err
}
if err := w.svc.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/webp"); err != nil {
_ = w.svc.store.DeleteObjectRow(ctx, objID)
return err
}
if err := w.svc.store.ActivateObject(ctx, objID); err != nil {
return err
}
bound := false
for _, pid := range photoIDs {
if err := w.svc.store.BindObject(ctx, pid, objID, role); err != nil {
if errors.Is(err, ErrObjectNotReady) || errors.Is(err, ErrObjectNotFound) {
continue
}
return err
}
bound = true
}
if !bound {
// No photo could take the variant; let the collector reclaim it.
_ = w.svc.store.MarkObjectDeleting(ctx, objID)
}
return nil
}
func (w *Worker) notify(ctx context.Context, photoID string) {
p, err := w.svc.store.GetByID(ctx, photoID, "")
if err != nil {
return
}
if err := w.svc.attachURLs(ctx, p); err != nil {
return
}
w.svc.hub.Publish(p.AlbumID, "photo.created", p)
}
func (w *Worker) fail(ctx context.Context, photoID, msg string) {
slog.Warn("variant job failed", "photo", photoID, "error", msg)
_ = w.svc.store.FailVariantJob(ctx, photoID, msg, maxVariantAttempts)
}
func (w *Worker) collectGarbage(ctx context.Context) {
objs, err := w.svc.store.ClaimGarbage(ctx, w.batch)
if err != nil {
slog.Error("claim garbage", "error", err)
return
}
for _, o := range objs {
w.deleteObject(ctx, o)
}
}
func (w *Worker) reapStalePending(ctx context.Context) {
objs, err := w.svc.store.ClaimStalePending(ctx, w.pendingTTL, w.batch)
if err != nil {
slog.Error("claim stale pending", "error", err)
return
}
for _, o := range objs {
w.deleteObject(ctx, o)
}
}
// deleteObject removes an object's blob then its row. If a binding appeared
// meanwhile the object is returned to ready; if the storage delete fails the
// row stays deleting and is retried on the next pass.
func (w *Worker) deleteObject(ctx context.Context, o Object) {
if n, err := w.svc.store.ObjectRefCount(ctx, o.ID); err == nil && n > 0 {
_ = w.svc.store.SetObjectState(ctx, o.ID, ObjectReady)
return
}
if err := w.svc.storage.Delete(ctx, o.StorageKey); err != nil {
slog.Warn("delete object blob", "object", o.ID, "error", err)
return
}
if err := w.svc.store.DeleteObjectRow(ctx, o.ID); err != nil && !errors.Is(err, ErrObjectNotFound) {
slog.Warn("delete object row", "object", o.ID, "error", err)
}
}
-92
View File
@@ -1,92 +0,0 @@
package photos
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/storage"
)
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
pool := testPool(t)
svc, cl := newTestService(t, pool)
runWorkerFlow(t, pool, svc, cl)
}
func TestWorkerGeneratesVariantsWithFS(t *testing.T) {
pool := testPool(t)
svc, cl := newTestServiceFS(t, pool)
runWorkerFlow(t, pool, svc, cl)
}
func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend) {
t.Helper()
ctx := context.Background()
user := createUser(t, ctx, pool)
album := createAlbum(t, ctx, pool, user)
p, err := svc.Upload(ctx, UploadInput{
AlbumID: album,
UserID: user,
Filename: "photo.png",
MimeType: "image/png",
Data: makePNG(t),
})
if err != nil {
t.Fatalf("upload: %v", err)
}
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
w := NewWorker(svc, time.Second, 10, time.Minute)
w.processVariantJobs(ctx)
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
if err != nil {
t.Fatalf("get refreshed: %v", err)
}
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, refreshed.PreviewKey)
}
if err := svc.attachURLs(ctx, refreshed); err != nil {
t.Fatalf("attach urls: %v", err)
}
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
t.Fatalf("variant urls not set")
}
var jobs int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM variant_jobs WHERE photo_id = $1`, p.ID).Scan(&jobs); err != nil {
t.Fatalf("count jobs: %v", err)
}
if jobs != 0 {
t.Fatalf("variant job not completed: %d left", jobs)
}
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
if err != nil || len(objectIDs) != 3 {
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
}
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
t.Fatalf("permanent delete: %v", err)
}
// Run the collector until the objects are gone (bounded to avoid hangs).
for attempt := 0; attempt < 100 && countObjects(t, ctx, pool, objectIDs) > 0; attempt++ {
w.collectGarbage(ctx)
}
if n := countObjects(t, ctx, pool, objectIDs); n != 0 {
t.Fatalf("%d objects were not collected", n)
}
}
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
t.Helper()
var n int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM objects WHERE id = ANY($1)`, ids).Scan(&n); err != nil {
t.Fatalf("count objects: %v", err)
}
return n
}
-74
View File
@@ -1,74 +0,0 @@
// Package reconcile computes the difference between the object storage and the
// metadata journal so orphaned blobs can be reported and, on request, removed.
package reconcile
import (
"sort"
"strings"
"time"
"shared-album/internal/storage"
)
// Result holds the two kinds of divergence between storage and the journal.
type Result struct {
// Orphans are keys present in storage but absent from the journal; they are
// candidates for deletion.
Orphans []string
// Missing are keys present in the journal but absent from storage; they are
// reported only and never modified.
Missing []string
}
// Classify compares the listed storage objects with the journaled keys.
func Classify(objects []storage.ObjectInfo, journaled []string) Result {
have := make(map[string]struct{}, len(journaled))
for _, k := range journaled {
have[k] = struct{}{}
}
seen := make(map[string]struct{}, len(objects))
var orphans []string
for _, o := range objects {
if _, dup := seen[o.Key]; dup {
continue
}
seen[o.Key] = struct{}{}
if _, ok := have[o.Key]; !ok {
orphans = append(orphans, o.Key)
}
}
var missing []string
for _, k := range journaled {
if _, ok := seen[k]; !ok {
missing = append(missing, k)
}
}
sort.Strings(orphans)
sort.Strings(missing)
return Result{Orphans: orphans, Missing: missing}
}
// Deletable selects orphaned keys that are safe to remove: within prefix and
// older than minAge (a guard against racing an in-flight upload whose row is
// not written yet).
func Deletable(objects []storage.ObjectInfo, res Result, prefix string, minAge time.Duration, now time.Time) []string {
orphans := make(map[string]struct{}, len(res.Orphans))
for _, k := range res.Orphans {
orphans[k] = struct{}{}
}
var out []string
for _, o := range objects {
if _, ok := orphans[o.Key]; !ok {
continue
}
if prefix != "" && !strings.HasPrefix(o.Key, prefix) {
continue
}
if now.Sub(o.ModTime) < minAge {
continue
}
out = append(out, o.Key)
}
sort.Strings(out)
return out
}
@@ -1,41 +0,0 @@
package reconcile
import (
"reflect"
"testing"
"time"
"shared-album/internal/storage"
)
func TestClassify(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/a/original.jpg", ModTime: now},
{Key: "objects/b/original.jpg", ModTime: now},
}
journaled := []string{"objects/b/original.jpg", "objects/c/original.jpg"}
got := Classify(objects, journaled)
if !reflect.DeepEqual(got.Orphans, []string{"objects/a/original.jpg"}) {
t.Fatalf("orphans = %v", got.Orphans)
}
if !reflect.DeepEqual(got.Missing, []string{"objects/c/original.jpg"}) {
t.Fatalf("missing = %v", got.Missing)
}
}
func TestDeletableRespectsAgeAndPrefix(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/old/original.jpg", ModTime: now.Add(-2 * time.Hour)},
{Key: "objects/new/original.jpg", ModTime: now.Add(-1 * time.Minute)},
{Key: "misc/other.bin", ModTime: now.Add(-2 * time.Hour)},
}
res := Result{Orphans: []string{"objects/old/original.jpg", "objects/new/original.jpg", "misc/other.bin"}}
got := Deletable(objects, res, "objects/", time.Hour, now)
if !reflect.DeepEqual(got, []string{"objects/old/original.jpg"}) {
t.Fatalf("deletable = %v, want only the old in-prefix orphan", got)
}
}
-560
View File
@@ -1,560 +0,0 @@
// Package contract holds a backend-agnostic test suite for the metadata
// repositories. The same suite runs against PostgreSQL and SQLite so both
// implementations are held to identical observable behaviour.
package contract
import (
"context"
"errors"
"testing"
"time"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain plus a Reset hook that clears all
// metadata so each subtest starts from an empty state.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Settings settings.Repository
Reset func(ctx context.Context) error
}
// Run executes the whole contract suite against the given repositories.
func Run(t *testing.T, r Repos) {
t.Helper()
cases := []struct {
name string
fn func(*testing.T, context.Context, Repos)
}{
{"Auth", testAuth},
{"Albums", testAlbums},
{"Members", testMembers},
{"Notifications", testNotifications},
{"ShareLinks", testShareLinks},
{"Social", testSocial},
{"Photos", testPhotos},
{"Objects", testObjects},
{"Settings", testSettings},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := r.Reset(context.Background()); err != nil {
t.Fatalf("reset: %v", err)
}
tc.fn(t, context.Background(), r)
})
}
}
func newID() string { return auth.NewID() }
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
t.Helper()
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
if err := r.Auth.CreateUser(ctx, u); err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
func seedAlbum(t *testing.T, ctx context.Context, r Repos, ownerID string) *models.Album {
t.Helper()
a := &models.Album{ID: newID(), OwnerID: ownerID, Title: "Album"}
if err := r.Albums.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
return a
}
func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo {
t.Helper()
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "p.jpg"}
if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
t.Fatalf("create photo: %v", err)
}
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
t.Fatalf("activate object: %v", err)
}
return p
}
func testAuth(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Tester")
byEmail, err := r.Auth.GetUserByEmail(ctx, u.Email)
if err != nil || byEmail.ID != u.ID {
t.Fatalf("get by email: %v id=%v", err, byEmail)
}
if _, err := r.Auth.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("get by id: %v", err)
}
if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("missing email err = %v, want ErrNotFound", err)
}
if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil {
t.Fatalf("update preferences: %v", err)
}
got, _ := r.Auth.GetUserByID(ctx, u.ID)
if got.Preferences["theme"] != "dark" {
t.Fatalf("preferences not applied: %+v", got.Preferences)
}
if err := r.Auth.UpdatePreferences(ctx, "missing", []byte(`{}`)); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("update missing prefs err = %v, want ErrNotFound", err)
}
hash := newID()
rt := &models.RefreshToken{ID: newID(), UserID: u.ID, TokenHash: hash, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.Auth.CreateRefreshToken(ctx, rt); err != nil {
t.Fatalf("create refresh: %v", err)
}
fetched, err := r.Auth.GetRefreshToken(ctx, hash)
if err != nil || fetched.ID != rt.ID {
t.Fatalf("get refresh: %v id=%v", err, fetched)
}
if err := r.Auth.RevokeRefreshToken(ctx, rt.ID); err != nil {
t.Fatalf("revoke refresh: %v", err)
}
fetched, _ = r.Auth.GetRefreshToken(ctx, hash)
if fetched.RevokedAt == nil {
t.Fatalf("refresh not revoked")
}
if err := r.Auth.RevokeAllUserTokens(ctx, u.ID); err != nil {
t.Fatalf("revoke all: %v", err)
}
}
func testAlbums(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
if a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
t.Fatalf("album timestamps not returned: %+v", a)
}
got, err := r.Albums.GetByID(ctx, a.ID)
if err != nil || got.Title != "Album" {
t.Fatalf("get: %v %+v", err, got)
}
if oid, err := r.Albums.OwnerID(ctx, a.ID); err != nil || oid != owner.ID {
t.Fatalf("owner id: %v %v", oid, err)
}
list, err := r.Albums.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
a.Title = "Renamed"
if err := r.Albums.Update(ctx, a); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = r.Albums.GetByID(ctx, a.ID)
if got.Title != "Renamed" {
t.Fatalf("update not applied: %q", got.Title)
}
if err := r.Albums.Delete(ctx, a.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := r.Albums.GetByID(ctx, a.ID); !errors.Is(err, albums.ErrNotFound) {
t.Fatalf("after delete err = %v, want ErrNotFound", err)
}
}
func testMembers(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
member := seedUser(t, ctx, r, "Member")
a := seedAlbum(t, ctx, r, owner.ID)
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("role before add = %q", role)
}
if err := r.Members.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
t.Fatalf("add: %v", err)
}
if role, err := r.Members.Role(ctx, a.ID, member.ID); err != nil || role != "member" {
t.Fatalf("role = %q err=%v", role, err)
}
list, err := r.Members.List(ctx, a.ID)
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "editor" {
t.Fatalf("role not updated: %q", role)
}
inv := &models.Invite{ID: newID(), AlbumID: a.ID, Token: newID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
if err := r.Members.CreateInvite(ctx, inv); err != nil {
t.Fatalf("create invite: %v", err)
}
if inv.CreatedAt.IsZero() {
t.Fatalf("invite created_at not returned")
}
got, err := r.Members.GetInviteByToken(ctx, inv.Token)
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
t.Fatalf("get invite: %v %+v", err, got)
}
if err := r.Members.IncrementUses(ctx, inv.ID); err != nil {
t.Fatalf("increment: %v", err)
}
got, _ = r.Members.GetInviteByToken(ctx, inv.Token)
if got.Uses != 1 {
t.Fatalf("uses = %d, want 1", got.Uses)
}
if _, err := r.Members.GetInviteByToken(ctx, "missing"); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("missing invite err = %v, want ErrNotFound", err)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); err != nil {
t.Fatalf("remove: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("member not removed: %q", role)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
}
}
func testNotifications(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Notify")
n := &models.Notification{ID: newID(), UserID: u.ID, Type: "test", PayloadJSON: map[string]any{"k": "v"}}
if err := r.Notifications.Create(ctx, n); err != nil {
t.Fatalf("create: %v", err)
}
if n.CreatedAt.IsZero() {
t.Fatalf("notification created_at not returned")
}
list, err := r.Notifications.List(ctx, u.ID, 10)
if err != nil || len(list) != 1 || list[0].PayloadJSON["k"] != "v" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Notifications.MarkRead(ctx, n.ID, u.ID); err != nil {
t.Fatalf("mark read: %v", err)
}
list, _ = r.Notifications.List(ctx, u.ID, 10)
if list[0].ReadAt == nil {
t.Fatalf("notification not marked read")
}
if err := r.Notifications.MarkRead(ctx, newID(), u.ID); !errors.Is(err, notifications.ErrNotFound) {
t.Fatalf("mark missing err = %v, want ErrNotFound", err)
}
}
func testShareLinks(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
l := &models.ShareLink{ID: newID(), AlbumID: a.ID, Token: newID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.ShareLinks.Create(ctx, l); err != nil {
t.Fatalf("create: %v", err)
}
if l.CreatedAt.IsZero() {
t.Fatalf("share link created_at not returned")
}
got, err := r.ShareLinks.GetByToken(ctx, l.Token)
if err != nil || !got.Active(time.Now()) {
t.Fatalf("get token: %v %+v", err, got)
}
list, err := r.ShareLinks.ListByAlbum(ctx, a.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
got, _ = r.ShareLinks.GetByToken(ctx, l.Token)
if got.Active(time.Now()) {
t.Fatalf("link still active after revoke")
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("revoke again err = %v, want ErrNotFound", err)
}
if _, err := r.ShareLinks.GetByToken(ctx, "missing"); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("missing token err = %v, want ErrNotFound", err)
}
}
func testSocial(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like: %v", err)
}
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like twice: %v", err)
}
if err := r.Social.Unlike(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("unlike: %v", err)
}
c := &models.Comment{ID: newID(), PhotoID: p.ID, UserID: owner.ID, Text: "hi"}
if err := r.Social.CreateComment(ctx, c); err != nil {
t.Fatalf("create comment: %v", err)
}
if c.CreatedAt.IsZero() || c.UpdatedAt.IsZero() {
t.Fatalf("comment timestamps not returned: %+v", c)
}
list, err := r.Social.ListComments(ctx, p.ID)
if err != nil || len(list) != 1 || list[0].Text != "hi" {
t.Fatalf("list comments: %v %+v", err, list)
}
got, err := r.Social.GetComment(ctx, c.ID)
if err != nil || got.Text != "hi" {
t.Fatalf("get comment: %v %+v", err, got)
}
if err := r.Social.DeleteComment(ctx, c.ID); err != nil {
t.Fatalf("delete comment: %v", err)
}
if _, err := r.Social.GetComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
if err := r.Social.DeleteComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("delete again err = %v, want ErrNotFound", err)
}
}
func testPhotos(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if p.CreatedAt.IsZero() {
t.Fatalf("photo created_at not returned")
}
got, err := r.Photos.GetByID(ctx, p.ID, "")
if err != nil || got.StorageKey == "" {
t.Fatalf("get photo: %v %+v", err, got)
}
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10})
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
tag, err := r.Photos.EnsureTag(ctx, owner.ID, "Vacation")
if err != nil || tag.Name != "Vacation" {
t.Fatalf("ensure tag: %v %+v", err, tag)
}
tag2, err := r.Photos.EnsureTag(ctx, owner.ID, "vacation")
if err != nil || tag2.ID != tag.ID {
t.Fatalf("ensure tag case-insensitive: %v %+v", err, tag2)
}
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("attach tag: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, owner.ID)
if len(got.Tags) != 1 || got.Tags[0].Name != "Vacation" {
t.Fatalf("photo tags = %+v", got.Tags)
}
if owned, _ := r.Photos.TagOwnedBy(ctx, tag.ID, owner.ID); !owned {
t.Fatalf("tag not owned")
}
tags, err := r.Photos.ListTags(ctx, owner.ID, "vac")
if err != nil || len(tags) != 1 {
t.Fatalf("list tags: %v len=%d", err, len(tags))
}
if err := r.Photos.DetachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("detach tag: %v", err)
}
r.Photos.SetCoverIfEmpty(ctx, a.ID, p.ID)
al, _ := r.Albums.GetByID(ctx, a.ID)
if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID {
t.Fatalf("cover not set: %+v", al.CoverPhotoID)
}
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
t.Fatalf("soft delete: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 1 {
t.Fatalf("trash count = %d, want 1", n)
}
trash, err := r.Photos.ListTrash(ctx, owner.ID)
if err != nil || len(trash) != 1 {
t.Fatalf("trash: %v len=%d", err, len(trash))
}
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 0 {
t.Fatalf("trash count after restore = %d", n)
}
if err := r.Photos.UpdateFilename(ctx, p.ID, "renamed.jpg"); err != nil {
t.Fatalf("update filename: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, "")
if got.OriginalFilename != "renamed.jpg" {
t.Fatalf("filename = %q", got.OriginalFilename)
}
if ok, _ := r.Photos.AlbumExists(ctx, a.ID); !ok {
t.Fatalf("album should exist")
}
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
if _, err := r.Photos.GetByID(ctx, p.ID, ""); !errors.Is(err, photos.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
}
func testObjects(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
orig, err := r.Photos.OriginalObject(ctx, p.ID)
if err != nil || orig.State != photos.ObjectReady {
t.Fatalf("original object: %v %+v", err, orig)
}
keys, err := r.Photos.AllStorageKeys(ctx)
if err != nil || !contains(keys, orig.StorageKey) {
t.Fatalf("AllStorageKeys = %v err=%v, want %s", keys, err, orig.StorageKey)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, orig.ID); refs != 1 {
t.Fatalf("refs = %d, want 1", refs)
}
sibs, _ := r.Photos.PhotosByOriginalObject(ctx, orig.ID)
if len(sibs) != 1 || sibs[0] != p.ID {
t.Fatalf("siblings = %+v", sibs)
}
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
t.Fatalf("create object: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); !errors.Is(err, photos.ErrObjectNotReady) {
t.Fatalf("bind pending err = %v, want ErrObjectNotReady", err)
}
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
t.Fatalf("activate: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
t.Fatalf("bind thumb: %v", err)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, thumb.ID); refs != 1 {
t.Fatalf("thumb refs = %d, want 1", refs)
}
// After the photo is gone, its objects have no bindings and are collectible.
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
got := map[string]bool{}
for _, o := range claimed {
got[o.ID] = true
}
if !got[orig.ID] || !got[thumb.ID] {
t.Fatalf("claimed = %+v, want original and thumb", got)
}
for _, o := range claimed {
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
t.Fatalf("delete object row: %v", err)
}
}
if again, _ := r.Photos.ClaimGarbage(ctx, 10); len(again) != 0 {
t.Fatalf("garbage reclaimed: %+v", again)
}
// Stale pending objects are reaped. A negative age puts the threshold in
// the future so the mechanism is exercised without depending on clock skew
// between the test process and the database.
stale := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, stale); err != nil {
t.Fatalf("create stale: %v", err)
}
staleClaimed, err := r.Photos.ClaimStalePending(ctx, -time.Hour, 10)
if err != nil || len(staleClaimed) == 0 {
t.Fatalf("claim stale: %v %+v", err, staleClaimed)
}
// Variant queue: a fresh photo has one job.
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
jobs, err := r.Photos.ClaimVariantJobs(ctx, 10, time.Minute)
if err != nil {
t.Fatalf("claim jobs: %v", err)
}
if !contains(jobs, p2.ID) {
t.Fatalf("jobs = %+v, want %s", jobs, p2.ID)
}
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
t.Fatalf("complete job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p2.ID) {
t.Fatalf("completed job still claimed: %+v", jobs)
}
// Failure schedules a retry and eventually abandons the job.
p3 := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("failed job retried immediately: %+v", jobs)
}
for i := 0; i < 3; i++ {
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job again: %v", err)
}
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("abandoned job still present: %+v", jobs)
}
}
func testSettings(t *testing.T, ctx context.Context, r Repos) {
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
if err != nil || v != "generated-1" {
t.Fatalf("ensure first: %v %q", err, v)
}
v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil })
if err != nil || v2 != "generated-1" {
t.Fatalf("ensure not idempotent: %v %q", err, v2)
}
got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey)
if err != nil || !ok || got != "generated-1" {
t.Fatalf("get: %v ok=%v %q", err, ok, got)
}
if _, ok, _ := r.Settings.Get(ctx, "missing"); ok {
t.Fatalf("missing key reported present")
}
if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil {
t.Fatalf("set: %v", err)
}
if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" {
t.Fatalf("set not applied: %q", got)
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
@@ -1,80 +0,0 @@
package contract
import (
"context"
"fmt"
"io/fs"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// TestPostgresContract runs the shared suite against PostgreSQL inside its own
// schema, so it never clashes with other packages that use the same database.
func TestPostgresContract(t *testing.T) {
base := os.Getenv("TEST_DATABASE_URL")
if base == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, base)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := admin.Ping(ctx); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(admin.Close)
schema := fmt.Sprintf("ct_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
})
dsn := base + "&options=-c%20search_path%3D" + schema
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.Migrate(dsn, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
Run(t, Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Settings: settings.NewStore(pool),
Reset: func(ctx context.Context) error {
_, err := pool.Exec(ctx, `TRUNCATE users, objects, app_settings CASCADE`)
return err
},
})
}
@@ -1,59 +0,0 @@
package contract
import (
"context"
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
func TestSQLiteContract(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { database.Close() })
Run(t, Repos{
Auth: auth.NewSQLiteStore(database),
Albums: albums.NewSQLiteStore(database),
Members: members.NewSQLiteStore(database),
Photos: photos.NewSQLiteStore(database),
Social: social.NewSQLiteStore(database),
Notifications: notifications.NewSQLiteStore(database),
ShareLinks: sharelinks.NewSQLiteStore(database),
Settings: settings.NewSQLiteStore(database),
Reset: func(ctx context.Context) error {
tables := []string{
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
"refresh_tokens", "share_links", "notifications", "likes", "comments",
"invites", "album_members", "photos", "albums", "users", "app_settings",
}
for _, tbl := range tables {
if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil {
return err
}
}
return nil
},
})
}
+95 -80
View File
@@ -14,6 +14,7 @@ import (
"shared-album/internal/models"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/sse"
)
func testPool(t *testing.T) *pgxpool.Pool {
@@ -29,7 +30,6 @@ func testPool(t *testing.T) *pgxpool.Pool {
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
@@ -49,6 +49,7 @@ func newUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string)
func TestUserRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
store := auth.NewStore(pool)
@@ -82,6 +83,7 @@ func TestUserRepository(t *testing.T) {
func TestAlbumRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
store := albums.NewStore(pool)
@@ -123,87 +125,9 @@ func TestAlbumRepository(t *testing.T) {
}
}
func TestAlbumCoverRepository(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
store := albums.NewStore(pool)
owner := newUser(t, ctx, pool, "CoverOwner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "Cover"}
if err := store.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
insertPhoto := func(id string) string {
t.Helper()
origObj := auth.NewID()
thumbObj := auth.NewID()
thumbKey := "objects/" + thumbObj + "/thumb.webp"
if _, err := pool.Exec(ctx,
`INSERT INTO objects (id, storage_key, mime_type, state) VALUES
($1, $2, 'image/jpeg', 'ready'),
($3, $4, 'image/webp', 'ready')`,
origObj, "objects/"+origObj+"/original.jpg", thumbObj, thumbKey); err != nil {
t.Fatalf("insert objects: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, $2, $3)`,
id, a.ID, owner.ID); err != nil {
t.Fatalf("insert photo: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'original'), ($1, $3, 'thumb')`,
id, origObj, thumbObj); err != nil {
t.Fatalf("insert bindings: %v", err)
}
return thumbKey
}
photo1 := auth.NewID()
thumb1 := insertPhoto(photo1)
got, err := store.GetByID(ctx, a.ID)
if err != nil || got.CoverThumbKey != "" {
t.Fatalf("expected empty cover key before set: %v %q", err, got.CoverThumbKey)
}
ps := photos.NewStore(pool)
ps.SetCoverIfEmpty(ctx, a.ID, photo1)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
}
if got.CoverThumbKey != thumb1 {
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, thumb1)
}
photo2 := auth.NewID()
thumb2 := insertPhoto(photo2)
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
t.Fatalf("existing cover was overridden: %+v", got.CoverPhotoID)
}
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo1); err != nil {
t.Fatalf("delete cover photo: %v", err)
}
got, _ = store.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared after delete: %+v", got.CoverPhotoID)
}
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
list, err := store.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 || list[0].CoverThumbKey != thumb2 {
t.Fatalf("list cover key: %v %+v", err, list)
}
}
func TestMemberRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
@@ -265,8 +189,99 @@ func TestMemberRepository(t *testing.T) {
}
}
func TestAlbumCover(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) })
albumStore := albums.NewStore(pool)
photosStore := photos.NewStore(pool)
a := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "A"}
if err := albumStore.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
p1 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k1", Width: 1, Height: 1}
p2 := &models.Photo{ID: auth.NewID(), AlbumID: a.ID, UploaderID: owner.ID, StorageKey: "k2", Width: 1, Height: 1}
for _, p := range []*models.Photo{p1, p2} {
if err := photosStore.Create(ctx, p); err != nil {
t.Fatalf("create photo: %v", err)
}
}
photosStore.SetCover(ctx, a.ID, p1.ID)
got, _ := albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
t.Fatalf("auto-cover not set: %v", got.CoverPhotoID)
}
photosStore.SetCover(ctx, a.ID, p2.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p1.ID {
t.Fatalf("auto-cover overwrote existing cover: %v", got.CoverPhotoID)
}
photosStore.ClearCover(ctx, a.ID, p2.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil {
t.Fatalf("clear with non-cover photo should be a no-op")
}
photosStore.ClearCover(ctx, a.ID, p1.ID)
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared: %v", got.CoverPhotoID)
}
if ok, err := photosStore.PhotoInAlbum(ctx, p1.ID, a.ID); err != nil || !ok {
t.Fatalf("photo should belong to album: ok=%v err=%v", ok, err)
}
other := &models.Album{ID: auth.NewID(), OwnerID: owner.ID, Title: "B"}
if err := albumStore.Create(ctx, other); err != nil {
t.Fatalf("create other album: %v", err)
}
pForeign := &models.Photo{ID: auth.NewID(), AlbumID: other.ID, UploaderID: owner.ID, StorageKey: "k3", Width: 1, Height: 1}
if err := photosStore.Create(ctx, pForeign); err != nil {
t.Fatalf("create foreign photo: %v", err)
}
if ok, _ := photosStore.PhotoInAlbum(ctx, pForeign.ID, a.ID); ok {
t.Fatalf("foreign photo should not belong to album")
}
svc := albums.NewService(albumStore, members.NewStore(pool), sse.NewHub(16), nil, photosStore)
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &pForeign.ID}); err == nil {
t.Fatalf("expected error when setting foreign photo as cover")
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("foreign cover should not be applied: %v", got.CoverPhotoID)
}
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{CoverPhotoID: &p2.ID}); err != nil {
t.Fatalf("valid cover update: %v", err)
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID == nil || *got.CoverPhotoID != p2.ID {
t.Fatalf("valid cover not applied: %v", got.CoverPhotoID)
}
if _, err := svc.Update(ctx, a.ID, owner.ID, albums.UpdateInput{ClearCover: true}); err != nil {
t.Fatalf("clear cover via service: %v", err)
}
got, _ = albumStore.GetByID(ctx, a.ID)
if got.CoverPhotoID != nil {
t.Fatalf("cover not cleared via service: %v", got.CoverPhotoID)
}
}
func TestShareLinkRepository(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
owner := newUser(t, ctx, pool, "Owner")
-15
View File
@@ -1,15 +0,0 @@
package settings
import (
"crypto/rand"
"encoding/base64"
)
// GenerateSecret returns a new random secret suitable for HMAC signing.
func GenerateSecret() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
-20
View File
@@ -1,20 +0,0 @@
// Package settings persists application-level key/value settings in the
// metadata layer. The main use is the token signing secret, generated on first
// use and reused afterwards.
package settings
import "context"
// JWTSecretKey is the settings key holding the token signing secret.
const JWTSecretKey = "jwt_secret"
// Repository reads and writes application settings.
type Repository interface {
// Get returns the value for key and whether it exists.
Get(ctx context.Context, key string) (string, bool, error)
// Set stores or replaces the value for key.
Set(ctx context.Context, key, value string) error
// Ensure returns the existing value for key, or generates and stores one
// using generate when absent. Concurrent first use yields one value.
Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error)
}
-63
View File
@@ -1,63 +0,0 @@
package settings
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"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} }
func (s *SQLiteStore) Get(ctx context.Context, key string) (string, bool, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
return "", false, err
}
return v, true, nil
}
func (s *SQLiteStore) Set(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = $3`,
key, value, sqliteutil.FormatTime(time.Now()))
return err
}
func (s *SQLiteStore) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
if v, ok, err := s.Get(ctx, key); err != nil {
return "", err
} else if ok {
return v, nil
}
v, err := generate()
if err != nil {
return "", err
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
key, v); err != nil {
return "", err
}
got, ok, err := s.Get(ctx, key)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("settings: %q missing after insert", key)
}
return got, nil
}
-62
View File
@@ -1,62 +0,0 @@
package settings
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store is the PostgreSQL implementation of Repository.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
func (s *Store) Get(ctx context.Context, key string) (string, bool, error) {
var v string
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, err
}
return v, true, nil
}
func (s *Store) Set(ctx context.Context, key, value string) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
key, value)
return err
}
func (s *Store) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
if v, ok, err := s.Get(ctx, key); err != nil {
return "", err
} else if ok {
return v, nil
}
v, err := generate()
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx,
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
key, v); err != nil {
return "", err
}
got, ok, err := s.Get(ctx, key)
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("settings: %q missing after insert", key)
}
return got, nil
}
-15
View File
@@ -1,15 +0,0 @@
package sharelinks
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the share-links domain.
type Repository interface {
Create(ctx context.Context, l *models.ShareLink) error
ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error)
GetByToken(ctx context.Context, token string) (*models.ShareLink, error)
Revoke(ctx context.Context, id, albumID string) error
}
+2 -2
View File
@@ -16,11 +16,11 @@ type AlbumAccess interface {
}
type Service struct {
store Repository
store *Store
albums AlbumAccess
}
func NewService(store Repository, albums AlbumAccess) *Service {
func NewService(store *Store, albums AlbumAccess) *Service {
return &Service{store: store, albums: albums}
}
@@ -1,90 +0,0 @@
package sharelinks
import (
"context"
"database/sql"
"errors"
"time"
"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} }
func (s *SQLiteStore) Create(ctx context.Context, l *models.ShareLink) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO share_links (id, album_id, token, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at`,
l.ID, l.AlbumID, l.Token, l.CreatedBy, sqliteutil.FormatTime(l.ExpiresAt),
).Scan(&createdAt)
if err != nil {
return err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE album_id = $1 ORDER BY created_at DESC`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.ShareLink{}
for rows.Next() {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
if err := rows.Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt); err != nil {
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
out = append(out, l)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetByToken(ctx context.Context, token string) (*models.ShareLink, error) {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE token = $1`, token).
Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &l, nil
}
func (s *SQLiteStore) Revoke(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE share_links SET revoked_at = $3 WHERE id = $1 AND album_id = $2 AND revoked_at IS NULL`,
id, albumID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
-17
View File
@@ -1,17 +0,0 @@
package social
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the social domain.
type Repository interface {
Like(ctx context.Context, photoID, userID string) error
Unlike(ctx context.Context, photoID, userID string) error
CreateComment(ctx context.Context, c *models.Comment) error
ListComments(ctx context.Context, photoID string) ([]models.Comment, error)
GetComment(ctx context.Context, id string) (*models.Comment, error)
DeleteComment(ctx context.Context, id string) error
}
+2 -2
View File
@@ -25,14 +25,14 @@ type Notifier interface {
}
type Service struct {
store Repository
store *Store
albums AlbumAccess
photos PhotoLookup
hub *sse.Hub
notify Notifier
}
func NewService(store Repository, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify}
}
-101
View File
@@ -1,101 +0,0 @@
package social
import (
"context"
"database/sql"
"errors"
"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} }
func (s *SQLiteStore) Like(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO likes (photo_id, user_id) VALUES ($1, $2)
ON CONFLICT (photo_id, user_id) DO NOTHING`, photoID, userID)
return err
}
func (s *SQLiteStore) Unlike(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM likes WHERE photo_id = $1 AND user_id = $2`, photoID, userID)
return err
}
func (s *SQLiteStore) CreateComment(ctx context.Context, c *models.Comment) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO comments (id, photo_id, user_id, text)
VALUES ($1, $2, $3, $4)
RETURNING created_at, updated_at`,
c.ID, c.PhotoID, c.UserID, c.Text,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) ListComments(ctx context.Context, photoID string) ([]models.Comment, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.photo_id, c.user_id, c.text, c.created_at, c.updated_at,
u.id, u.name, COALESCE(u.avatar_url, '')
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.photo_id = $1
ORDER BY c.created_at`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Comment{}
for rows.Next() {
var c models.Comment
var createdAt, updatedAt string
if err := rows.Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt,
&c.User.ID, &c.User.Name, &c.User.AvatarURL); err != nil {
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
out = append(out, c)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetComment(ctx context.Context, id string) (*models.Comment, error) {
var c models.Comment
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, photo_id, user_id, text, created_at, updated_at FROM comments WHERE id = $1`, id).
Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &c, nil
}
func (s *SQLiteStore) DeleteComment(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM comments WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
-50
View File
@@ -1,50 +0,0 @@
// Package sqliteutil holds small helpers shared by the SQLite repositories:
// timestamp conversion and dynamic IN-lists.
package sqliteutil
import (
"database/sql"
"strconv"
"strings"
"time"
)
// TimeLayout matches the SQLite column defaults produced by strftime with %f.
const TimeLayout = "2006-01-02T15:04:05.000Z"
// FormatTime renders a time in the canonical TEXT format used by the schema.
func FormatTime(t time.Time) string { return t.UTC().Format(TimeLayout) }
// ParseTime parses a canonical timestamp, tolerating the RFC3339 variants the
// driver may return. An empty or unparseable value yields the zero time.
func ParseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(TimeLayout, s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return time.Time{}
}
// ParseNullTime converts a nullable TEXT timestamp into *time.Time.
func ParseNullTime(ns sql.NullString) *time.Time {
if !ns.Valid || ns.String == "" {
return nil
}
t := ParseTime(ns.String)
return &t
}
// Placeholders returns an IN-list placeholder group of n items starting at
// index start, e.g. Placeholders(3, 2) -> "$3,$4".
func Placeholders(start, n int) string {
parts := make([]string, n)
for i := 0; i < n; i++ {
parts[i] = "$" + strconv.Itoa(start+i)
}
return strings.Join(parts, ",")
}
-233
View File
@@ -1,233 +0,0 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
)
// errInvalidKey is returned for object keys that are empty, absolute or attempt
// to escape the storage root.
var errInvalidKey = errors.New("fs: invalid object key")
// FS is a Backend backed by a local directory tree. Blobs live under Root at the
// path formed by the opaque key. Because a local directory has no external URL,
// FS serves blobs through the app using the shared Signer.
type FS struct {
root string
signer *Signer
}
// NewFS builds a local file-system storage backend rooted at root. signer issues
// and validates the app URLs used to serve blobs.
func NewFS(root string, signer *Signer) (*FS, error) {
if strings.TrimSpace(root) == "" {
return nil, errors.New("fs: root is required")
}
if signer == nil {
return nil, errors.New("fs: signer is required")
}
abs, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("fs: resolve root: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("fs: create root: %w", err)
}
return &FS{root: abs, signer: signer}, nil
}
func (f *FS) Bucket() string { return f.root }
func (f *FS) EnsureBucket(context.Context) error {
return os.MkdirAll(f.root, 0o755)
}
func (f *FS) Ping(context.Context) error {
info, err := os.Stat(f.root)
if err != nil {
return err
}
if !info.IsDir() {
return errors.New("fs: root is not a directory")
}
return nil
}
// objectPath maps an opaque key to an absolute path inside the root, rejecting
// absolute keys and any key that resolves outside the root.
func (f *FS) objectPath(key string) (string, error) {
if key == "" || filepath.IsAbs(key) {
return "", errInvalidKey
}
clean := filepath.Clean(key)
if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", errInvalidKey
}
p := filepath.Join(f.root, clean)
rel, err := filepath.Rel(f.root, p)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errInvalidKey
}
return p, nil
}
func (f *FS) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error {
p, err := f.objectPath(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return fmt.Errorf("fs: mkdir: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(p), ".upload-*")
if err != nil {
return fmt.Errorf("fs: temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := io.Copy(tmp, body); err != nil {
_ = tmp.Close()
return fmt.Errorf("fs: write: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("fs: close: %w", err)
}
if err := os.Rename(tmpName, p); err != nil {
return fmt.Errorf("fs: rename: %w", err)
}
return nil
}
func (f *FS) Get(_ context.Context, key string) ([]byte, error) {
p, err := f.objectPath(key)
if err != nil {
return nil, err
}
data, err := os.ReadFile(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrObjectNotFound
}
return nil, err
}
return data, nil
}
func (f *FS) Delete(_ context.Context, keys ...string) error {
var errs []error
for _, key := range keys {
p, err := f.objectPath(key)
if err != nil {
errs = append(errs, err)
continue
}
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// URL returns a signed app URL served by this process.
func (f *FS) URL(_ context.Context, key string) (string, error) {
if _, err := f.objectPath(key); err != nil {
return "", err
}
return f.signer.URL(key), nil
}
// List returns every blob under prefix as a key relative to the storage root.
func (f *FS) List(_ context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(f.root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if prefix != "" && !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
out = append(out, ObjectInfo{Key: rel, Size: info.Size(), ModTime: info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ServeObject validates the signed URL and serves the blob. It supports range
// requests and sets the content type from the key extension. It writes the
// response itself and returns nil on success.
func (f *FS) ServeObject(w http.ResponseWriter, r *http.Request, key string) error {
if unescaped, err := url.PathUnescape(key); err == nil {
key = unescaped
}
if err := f.signer.VerifyRequest(key, r); err != nil {
return err
}
p, err := f.objectPath(key)
if err != nil {
return ErrSignedURLInvalid
}
file, err := os.Open(p)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrObjectNotFound
}
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
w.Header().Set("Content-Type", contentTypeForKey(key))
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
return nil
}
var imageContentTypes = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
".tiff": "image/tiff",
".tif": "image/tiff",
".bmp": "image/bmp",
".avif": "image/avif",
}
func contentTypeForKey(key string) string {
ext := strings.ToLower(filepath.Ext(key))
if ct, ok := imageContentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
-204
View File
@@ -1,204 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
)
func newTestFS(t *testing.T) *FS {
t.Helper()
signer, err := NewSigner("http://app.test", "test-secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
fs, err := NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("new fs: %v", err)
}
return fs
}
func TestFSRoundTrip(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
data := []byte("hello blob")
if err := fs.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
got, err := fs.Get(ctx, key)
if err != nil {
t.Fatalf("get: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("get = %q, want %q", got, data)
}
if err := fs.Delete(ctx, key); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := fs.Get(ctx, key); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("get after delete = %v, want ErrObjectNotFound", err)
}
}
func TestFSPathTraversalRejected(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
for _, key := range []string{"../escape", "../../etc/passwd", "/etc/passwd", ""} {
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err == nil {
t.Errorf("put %q: expected rejection", key)
}
if _, err := fs.Get(ctx, key); err == nil {
t.Errorf("get %q: expected rejection", key)
}
if _, err := fs.URL(ctx, key); err == nil {
t.Errorf("url %q: expected rejection", key)
}
}
}
func TestFSURLAndServe(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/thumb.webp"
if err := fs.Put(ctx, key, strings.NewReader("webp-bytes"), 10, "image/webp"); err != nil {
t.Fatalf("put: %v", err)
}
raw, err := fs.URL(ctx, key)
if err != nil {
t.Fatalf("url: %v", err)
}
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse url: %v", err)
}
if !strings.HasPrefix(u.Path, "/api/objects/objects/abc/thumb.webp") {
t.Fatalf("unexpected path: %s", u.Path)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, raw, nil)
if err := fs.ServeObject(rec, req, strings.TrimPrefix(u.Path, "/api/objects/")); err != nil {
t.Fatalf("serve: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "image/webp" {
t.Fatalf("content-type = %q, want image/webp", ct)
}
if rec.Body.String() != "webp-bytes" {
t.Fatalf("body = %q", rec.Body.String())
}
}
func TestFSServeExpiredAndForged(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
past := time.Now().Add(-time.Minute).Unix()
expired := "/api/objects/" + key + "?e=" + strconv.FormatInt(past, 10) + "&s=" + fs.signer.sign(key, past)
rec := httptest.NewRecorder()
err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, expired, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("expired = %v, want ErrSignedURLInvalid", err)
}
future := time.Now().Add(time.Hour).Unix()
forged := "/api/objects/" + key + "?e=" + strconv.FormatInt(future, 10) + "&s=deadbeef"
rec = httptest.NewRecorder()
err = fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, forged, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("forged = %v, want ErrSignedURLInvalid", err)
}
}
func TestFSServeRangeAndMissing(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
key := "objects/abc/original.jpg"
if err := fs.Put(ctx, key, strings.NewReader("0123456789"), 10, "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
exp := time.Now().Add(time.Hour).Unix()
base := "/api/objects/" + key + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(key, exp)
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=2-5")
rec := httptest.NewRecorder()
if err := fs.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve range: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if body, _ := io.ReadAll(rec.Body); string(body) != "2345" {
t.Fatalf("range body = %q, want 2345", body)
}
missingKey := "objects/def/original.jpg"
missing := "/api/objects/" + missingKey + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(missingKey, exp)
rec = httptest.NewRecorder()
err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, missing, nil), missingKey)
if !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("missing = %v, want ErrObjectNotFound", err)
}
}
func TestContentTypeForKey(t *testing.T) {
cases := map[string]string{
"objects/a/original.jpg": "image/jpeg",
"objects/a/thumb.webp": "image/webp",
"objects/a/odd.unknownext": "application/octet-stream",
}
for key, want := range cases {
if got := contentTypeForKey(key); got != want {
t.Errorf("contentTypeForKey(%q) = %q, want %q", key, got, want)
}
}
}
func TestFSList(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
for _, key := range []string{"objects/a/original.jpg", "objects/b/original.jpg", "misc/readme.txt"} {
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err != nil {
t.Fatalf("put %s: %v", key, err)
}
}
all, err := fs.List(ctx, "")
if err != nil || len(all) != 3 {
t.Fatalf("list all: %v len=%d", err, len(all))
}
objs, err := fs.List(ctx, "objects/")
if err != nil {
t.Fatalf("list objects: %v", err)
}
if len(objs) != 2 {
t.Fatalf("list objects len=%d, want 2", len(objs))
}
for _, o := range objs {
if !strings.HasPrefix(o.Key, "objects/") {
t.Fatalf("unexpected key %q", o.Key)
}
if o.Size != 1 || o.ModTime.IsZero() {
t.Fatalf("unexpected info %+v", o)
}
}
}
-169
View File
@@ -1,169 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"shared-album/internal/config"
)
func newTestS3(t *testing.T) (*S3, *Signer) {
t.Helper()
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
signer, err := NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
cl, err := NewS3(context.Background(), config.S3{
Endpoint: endpoint,
Region: os.Getenv("S3_REGION"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Bucket: os.Getenv("S3_BUCKET"),
UsePathStyle: true,
}, signer)
if err != nil {
t.Skipf("storage init: %v", err)
}
return cl, signer
}
func signedQuery(signer *Signer, key string, exp int64) string {
return "?e=" + strconv.FormatInt(exp, 10) + "&s=" + signer.sign(key, exp)
}
func TestS3ServeObject(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
data := []byte("0123456789")
if err := cl.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
exp := time.Now().Add(time.Hour).Unix()
base := "/api/objects/" + key + signedQuery(signer, key, exp)
t.Run("full", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
if err := cl.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), data) {
t.Fatalf("body = %q", rec.Body.String())
}
})
t.Run("range", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=2-5")
if err := cl.ServeObject(rec, req, key); err != nil {
t.Fatalf("serve range: %v", err)
}
if rec.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", rec.Code)
}
if body, _ := io.ReadAll(rec.Body); string(body) != "2345" {
t.Fatalf("range body = %q, want 2345", body)
}
})
t.Run("invalid range", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, base, nil)
req.Header.Set("Range", "bytes=1000-2000")
err := cl.ServeObject(rec, req, key)
if !errors.Is(err, ErrRangeNotSatisfiable) {
t.Fatalf("err = %v, want ErrRangeNotSatisfiable", err)
}
})
t.Run("missing", func(t *testing.T) {
missingKey := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
target := "/api/objects/" + missingKey + signedQuery(signer, missingKey, exp)
rec := httptest.NewRecorder()
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), missingKey)
if !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("err = %v, want ErrObjectNotFound", err)
}
})
t.Run("expired signature", func(t *testing.T) {
past := time.Now().Add(-time.Minute).Unix()
target := "/api/objects/" + key + signedQuery(signer, key, past)
rec := httptest.NewRecorder()
err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), key)
if !errors.Is(err, ErrSignedURLInvalid) {
t.Fatalf("err = %v, want ErrSignedURLInvalid", err)
}
})
}
func TestS3List(t *testing.T) {
ctx := context.Background()
cl, _ := newTestS3(t)
prefix := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/"
keys := []string{prefix + "original.jpg", prefix + "thumb.webp"}
for _, k := range keys {
if err := cl.Put(ctx, k, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
t.Fatalf("put %s: %v", k, err)
}
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), keys...) })
got, err := cl.List(ctx, prefix)
if err != nil {
t.Fatalf("list: %v", err)
}
found := map[string]bool{}
for _, o := range got {
found[o.Key] = true
}
for _, k := range keys {
if !found[k] {
t.Fatalf("key %s not listed: %+v", k, got)
}
}
}
func TestS3ServeObjectHead(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg"
data := []byte("head-check")
if err := cl.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)), "image/jpeg"); err != nil {
t.Fatalf("put: %v", err)
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), key) })
exp := time.Now().Add(time.Hour).Unix()
target := "/api/objects/" + key + signedQuery(signer, key, exp)
rec := httptest.NewRecorder()
if err := cl.ServeObject(rec, httptest.NewRequest(http.MethodHead, target, nil), key); err != nil {
t.Fatalf("head: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if rec.Body.Len() != 0 {
t.Fatalf("HEAD body should be empty, got %d bytes", rec.Body.Len())
}
}
-80
View File
@@ -1,80 +0,0 @@
package storage
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// Signer produces and verifies the signed app URLs used by the /api/objects
// route. It is shared by every storage backend so delivery is identical: the
// client always talks to the backend, never to object storage directly.
type Signer struct {
secret []byte
baseURL string
ttl time.Duration
}
// NewSigner builds a signer. secret signs the URLs (required); baseURL is the
// public base URL of the app; ttl limits URL lifetime (defaults to one hour).
func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) {
if strings.TrimSpace(secret) == "" {
return nil, errors.New("url secret is required")
}
if ttl <= 0 {
ttl = time.Hour
}
return &Signer{
secret: []byte(secret),
baseURL: strings.TrimRight(baseURL, "/"),
ttl: ttl,
}, nil
}
// URL returns a signed app URL for the given opaque key.
func (s *Signer) URL(key string) string {
exp := time.Now().Add(s.ttl).Unix()
q := url.Values{}
q.Set("e", strconv.FormatInt(exp, 10))
q.Set("s", s.sign(key, exp))
return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode())
}
// VerifyRequest checks the signature and expiry carried by an object request.
// It returns ErrSignedURLInvalid when the request is not authorized.
func (s *Signer) VerifyRequest(key string, r *http.Request) error {
q := r.URL.Query()
exp, err := strconv.ParseInt(q.Get("e"), 10, 64)
if err != nil || !s.verify(key, exp, q.Get("s")) {
return ErrSignedURLInvalid
}
return nil
}
func (s *Signer) sign(key string, exp int64) string {
mac := hmac.New(sha256.New, s.secret)
_, _ = fmt.Fprintf(mac, "%s\n%d", key, exp)
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Signer) verify(key string, exp int64, sig string) bool {
if sig == "" || time.Now().Unix() > exp {
return false
}
return hmac.Equal([]byte(s.sign(key, exp)), []byte(sig))
}
func escapeKey(key string) string {
parts := strings.Split(key, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
+31 -212
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -20,58 +18,14 @@ import (
"shared-album/internal/config"
)
// ErrSignedURLInvalid is returned when an app-served object URL has a missing,
// malformed, expired or forged signature.
var ErrSignedURLInvalid = errors.New("invalid or expired object signature")
// ErrObjectNotFound is returned when the requested blob does not exist.
var ErrObjectNotFound = errors.New("object not found")
// ErrRangeNotSatisfiable is returned when a requested byte range is invalid.
var ErrRangeNotSatisfiable = errors.New("requested range not satisfiable")
// ObjectInfo describes one stored blob for inventory and reconciliation.
type ObjectInfo struct {
Key string
Size int64
ModTime time.Time
type Client struct {
s3 *s3.Client
presign *s3.PresignClient
bucket string
presignTTL time.Duration
}
// Backend is the object storage abstraction. Keys are opaque and independent of
// the backend: every implementation stores and serves the same logical blob.
// Blobs are always delivered through the app.
type Backend interface {
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
Delete(ctx context.Context, keys ...string) error
Get(ctx context.Context, key string) ([]byte, error)
// URL returns a time-limited signed app URL the client can use to fetch the blob.
URL(ctx context.Context, key string) (string, error)
// ServeObject validates the signed URL and writes the blob, including
// Content-Type and range handling, to the response.
ServeObject(w http.ResponseWriter, r *http.Request, key string) error
// List returns the blobs under prefix for inventory and reconciliation.
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
Ping(ctx context.Context) error
EnsureBucket(ctx context.Context) error
// Bucket returns a human-readable identifier for the storage location.
Bucket() string
}
// S3 is a Backend backed by an S3-compatible object store (Garage). Blobs are
// delivered through the app: the client receives a signed app URL and the bytes
// are streamed from S3 over the internal network.
type S3 struct {
s3 *s3.Client
bucket string
signer *Signer
}
// NewS3 builds an S3-compatible storage backend. signer issues the app URLs used
// to serve blobs.
func NewS3(ctx context.Context, cfg config.S3, signer *Signer) (*S3, error) {
if signer == nil {
return nil, errors.New("s3: signer is required")
}
func New(ctx context.Context, cfg config.S3) (*Client, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
@@ -87,42 +41,17 @@ func NewS3(ctx context.Context, cfg config.S3, signer *Signer) (*S3, error) {
o.UsePathStyle = cfg.UsePathStyle
})
return &S3{
s3: client,
bucket: cfg.Bucket,
signer: signer,
return &Client{
s3: client,
presign: s3.NewPresignClient(client),
bucket: cfg.Bucket,
presignTTL: cfg.PresignTTL,
}, nil
}
func (c *S3) Bucket() string { return c.bucket }
func (c *Client) Bucket() string { return c.bucket }
// Open builds the storage backend selected by configuration: S3-compatible by
// default, or the local file system when STORAGE_BACKEND=fs. Every backend
// delivers blobs through the app using a shared Signer. jwtSecret is the
// application secret used to sign object URLs when URL_SECRET is not set.
func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, error) {
secret := cfg.Storage.URLSecret
if secret == "" {
secret = jwtSecret
}
if secret == "" {
return nil, errors.New("object URL secret is not available")
}
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL)
if err != nil {
return nil, err
}
switch cfg.Storage.Backend {
case "", "s3":
return NewS3(ctx, cfg.S3, signer)
case "fs":
return NewFS(cfg.Storage.FSRoot, signer)
default:
return nil, fmt.Errorf("unknown storage backend %q", cfg.Storage.Backend)
}
}
func (c *S3) EnsureBucket(ctx context.Context) error {
func (c *Client) EnsureBucket(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
if err == nil {
return nil
@@ -137,12 +66,12 @@ func (c *S3) EnsureBucket(ctx context.Context) error {
return nil
}
func (c *S3) Ping(ctx context.Context) error {
func (c *Client) Ping(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
return err
}
func (c *S3) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
in := &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
@@ -158,7 +87,7 @@ func (c *S3) Put(ctx context.Context, key string, body io.Reader, size int64, co
return nil
}
func (c *S3) Delete(ctx context.Context, keys ...string) error {
func (c *Client) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
@@ -175,143 +104,33 @@ func (c *S3) Delete(ctx context.Context, keys ...string) error {
return nil
}
func (c *S3) Get(ctx context.Context, key string) ([]byte, error) {
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
func (c *Client) DeletePrefix(ctx context.Context, prefix string) error {
var keys []string
paginator := s3.NewListObjectsV2Paginator(c.s3, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Prefix: aws.String(prefix),
})
if err != nil {
return nil, fmt.Errorf("get object %s: %w", key, err)
}
defer out.Body.Close()
data, err := io.ReadAll(out.Body)
if err != nil {
return nil, fmt.Errorf("read object %s: %w", key, err)
}
return data, nil
}
// URL returns a signed app URL. The blob itself is streamed by ServeObject, so
// the S3 endpoint is never exposed to the client.
func (c *S3) URL(_ context.Context, key string) (string, error) {
return c.signer.URL(key), nil
}
// List returns every object under prefix using paginated ListObjectsV2.
func (c *S3) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
var token *string
for {
in := &s3.ListObjectsV2Input{Bucket: aws.String(c.bucket)}
if prefix != "" {
in.Prefix = aws.String(prefix)
}
if token != nil {
in.ContinuationToken = token
}
res, err := c.s3.ListObjectsV2(ctx, in)
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
return fmt.Errorf("list objects: %w", err)
}
for _, o := range res.Contents {
out = append(out, ObjectInfo{
Key: aws.ToString(o.Key),
Size: aws.ToInt64(o.Size),
ModTime: aws.ToTime(o.LastModified),
})
for _, o := range page.Contents {
keys = append(keys, aws.ToString(o.Key))
}
if res.IsTruncated == nil || !*res.IsTruncated {
return out, nil
}
token = res.NextContinuationToken
}
return c.Delete(ctx, keys...)
}
// ServeObject validates the signed URL and streams the blob from S3. It passes
// the client's Range header through to S3, so byte ranges are handled natively,
// and answers HEAD via HeadObject. It writes the response itself.
func (c *S3) ServeObject(w http.ResponseWriter, r *http.Request, key string) error {
if err := c.signer.VerifyRequest(key, r); err != nil {
return err
}
if r.Method == http.MethodHead {
return c.headObject(w, r, key)
}
in := &s3.GetObjectInput{Bucket: aws.String(c.bucket), Key: aws.String(key)}
if rng := r.Header.Get("Range"); rng != "" {
in.Range = aws.String(rng)
}
out, err := c.s3.GetObject(r.Context(), in)
if err != nil {
if isNoSuchKey(err) {
return ErrObjectNotFound
}
if isInvalidRange(err) {
return ErrRangeNotSatisfiable
}
return err
}
defer out.Body.Close()
if ct := aws.ToString(out.ContentType); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", contentTypeForKey(key))
}
if out.ContentLength != nil {
w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10))
}
if out.ContentRange != nil {
w.Header().Set("Content-Range", *out.ContentRange)
}
status := http.StatusOK
if out.ContentRange != nil {
status = http.StatusPartialContent
}
w.WriteHeader(status)
if _, err := io.Copy(w, out.Body); err != nil {
return err
}
return nil
}
func (c *S3) headObject(w http.ResponseWriter, r *http.Request, key string) error {
out, err := c.s3.HeadObject(r.Context(), &s3.HeadObjectInput{
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) {
out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
}, s3.WithPresignExpires(c.presignTTL))
if err != nil {
if isNoSuchKey(err) {
return ErrObjectNotFound
}
return err
return "", fmt.Errorf("presign %s: %w", key, err)
}
if ct := aws.ToString(out.ContentType); ct != "" {
w.Header().Set("Content-Type", ct)
} else {
w.Header().Set("Content-Type", contentTypeForKey(key))
}
if out.ContentLength != nil {
w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10))
}
w.WriteHeader(http.StatusOK)
return nil
}
func isNoSuchKey(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
code := apiErr.ErrorCode()
return strings.EqualFold(code, "NoSuchKey") || strings.EqualFold(code, "NotFound")
}
return false
}
func isInvalidRange(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return strings.EqualFold(apiErr.ErrorCode(), "InvalidRange")
}
return false
return out.URL, nil
}
func isNoSuchBucket(err error) bool {
-59
View File
@@ -1,59 +0,0 @@
// Package store builds the per-domain metadata repositories for a chosen
// backend. Domains depend on their Repository interfaces; this factory is the
// single place that knows the concrete implementations.
package store
import (
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/settings"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Settings settings.Repository
}
// NewPostgres builds the repositories backed by PostgreSQL.
func NewPostgres(pool *pgxpool.Pool) Repos {
return Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Settings: settings.NewStore(pool),
}
}
// NewSQLite builds the repositories backed by SQLite.
func NewSQLite(db *sql.DB) Repos {
return Repos{
Auth: auth.NewSQLiteStore(db),
Albums: albums.NewSQLiteStore(db),
Members: members.NewSQLiteStore(db),
Photos: photos.NewSQLiteStore(db),
Social: social.NewSQLiteStore(db),
Notifications: notifications.NewSQLiteStore(db),
ShareLinks: sharelinks.NewSQLiteStore(db),
Settings: settings.NewSQLiteStore(db),
}
}
-55
View File
@@ -1,55 +0,0 @@
package store
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/db"
)
func assertComplete(t *testing.T, r Repos) {
t.Helper()
if r.Auth == nil || r.Albums == nil || r.Members == nil || r.Photos == nil ||
r.Social == nil || r.Notifications == nil || r.ShareLinks == nil {
t.Fatalf("incomplete repository bundle: %+v", r)
}
}
func TestNewSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer database.Close()
assertComplete(t, NewSQLite(database))
}
func TestNewPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
assertComplete(t, NewPostgres(pool))
}
@@ -1 +0,0 @@
ALTER TABLE albums DROP CONSTRAINT IF EXISTS albums_cover_photo_fk;
@@ -1,9 +0,0 @@
-- Clear any dangling cover references before adding the constraint.
UPDATE albums
SET cover_photo_id = NULL
WHERE cover_photo_id IS NOT NULL
AND cover_photo_id NOT IN (SELECT id FROM photos);
ALTER TABLE albums
ADD CONSTRAINT albums_cover_photo_fk
FOREIGN KEY (cover_photo_id) REFERENCES photos(id) ON DELETE SET NULL;
@@ -1 +0,0 @@
ALTER TABLE photos DROP COLUMN IF EXISTS exif;
@@ -1 +0,0 @@
ALTER TABLE photos ADD COLUMN IF NOT EXISTS exif jsonb NOT NULL DEFAULT '{}'::jsonb;
@@ -1 +0,0 @@
ALTER TABLE users DROP COLUMN IF EXISTS preferences;
@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferences jsonb NOT NULL DEFAULT '{}'::jsonb;
@@ -1,2 +0,0 @@
DROP INDEX IF EXISTS photos_deleted_idx;
ALTER TABLE photos DROP COLUMN IF EXISTS deleted_at;
@@ -1,2 +0,0 @@
ALTER TABLE photos ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
CREATE INDEX IF NOT EXISTS photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
@@ -1,5 +0,0 @@
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
DELETE FROM photos WHERE album_id IS NULL;
ALTER TABLE photos ALTER COLUMN album_id SET NOT NULL;
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE CASCADE;
@@ -1,4 +0,0 @@
ALTER TABLE photos DROP CONSTRAINT IF EXISTS photos_album_id_fkey;
ALTER TABLE photos ALTER COLUMN album_id DROP NOT NULL;
ALTER TABLE photos ADD CONSTRAINT photos_album_id_fkey
FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL;
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS photo_tags;
DROP TABLE IF EXISTS tags;
@@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS tags (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_user_name_idx ON tags (user_id, lower(name));
CREATE TABLE IF NOT EXISTS photo_tags (
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
tag_id text NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
);
CREATE INDEX IF NOT EXISTS photo_tags_tag_idx ON photo_tags (tag_id);
@@ -1,3 +0,0 @@
DROP TABLE IF EXISTS variant_jobs;
DROP TABLE IF EXISTS photo_objects;
DROP TABLE IF EXISTS objects;
@@ -1,39 +0,0 @@
-- Physical objects stored in S3. Keys are opaque and never encode album, photo
-- or any other metadata entity. The row is the ledger entry that governs the
-- lifecycle of the corresponding blob.
CREATE TABLE IF NOT EXISTS objects (
id text PRIMARY KEY,
storage_key text NOT NULL UNIQUE,
mime_type text NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
width integer NOT NULL DEFAULT 0,
height integer NOT NULL DEFAULT 0,
checksum text NOT NULL DEFAULT '',
state text NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'ready', 'deleting')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS objects_state_created_idx ON objects (state, created_at);
-- Binding of a photo to its objects by role. The role is a property of the
-- binding, not of the object itself.
CREATE TABLE IF NOT EXISTS photo_objects (
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
object_id text NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
role text NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
PRIMARY KEY (photo_id, role)
);
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
-- Durable queue of pending variant generation work, one job per photo.
CREATE TABLE IF NOT EXISTS variant_jobs (
photo_id text PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS variant_jobs_due_idx ON variant_jobs (next_attempt_at);
@@ -1,8 +0,0 @@
ALTER TABLE photos
ADD COLUMN IF NOT EXISTS storage_key text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS thumb_key text,
ADD COLUMN IF NOT EXISTS preview_key text,
ADD COLUMN IF NOT EXISTS size bigint NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS width integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS height integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS mime_type text NOT NULL DEFAULT '';
@@ -1,10 +0,0 @@
-- Physical facts move to the objects table; photos keeps only membership and
-- presentation data. size/width/height/mime_type are derived from the original
-- object via photo_objects + objects.
ALTER TABLE photos DROP COLUMN IF EXISTS storage_key;
ALTER TABLE photos DROP COLUMN IF EXISTS thumb_key;
ALTER TABLE photos DROP COLUMN IF EXISTS preview_key;
ALTER TABLE photos DROP COLUMN IF EXISTS size;
ALTER TABLE photos DROP COLUMN IF EXISTS width;
ALTER TABLE photos DROP COLUMN IF EXISTS height;
ALTER TABLE photos DROP COLUMN IF EXISTS mime_type;
@@ -1 +0,0 @@
DROP TABLE IF EXISTS app_settings;
@@ -1,6 +0,0 @@
CREATE TABLE IF NOT EXISTS app_settings (
key text PRIMARY KEY,
value text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -1,15 +0,0 @@
DROP TABLE IF EXISTS variant_jobs;
DROP TABLE IF EXISTS photo_objects;
DROP TABLE IF EXISTS objects;
DROP TABLE IF EXISTS photo_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS share_links;
DROP TABLE IF EXISTS notifications;
DROP TABLE IF EXISTS likes;
DROP TABLE IF EXISTS comments;
DROP TABLE IF EXISTS invites;
DROP TABLE IF EXISTS album_members;
DROP TABLE IF EXISTS photos;
DROP TABLE IF EXISTS albums;
DROP TABLE IF EXISTS users;
@@ -1,159 +0,0 @@
-- Consolidated SQLite schema equivalent to the PostgreSQL migration history.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
avatar_url TEXT,
preferences TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE albums (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
cover_photo_id TEXT REFERENCES photos(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE photos (
id TEXT PRIMARY KEY,
album_id TEXT REFERENCES albums(id) ON DELETE SET NULL,
uploader_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL DEFAULT '',
taken_at TEXT,
latitude REAL,
longitude REAL,
caption TEXT NOT NULL DEFAULT '',
exif TEXT NOT NULL DEFAULT '{}',
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX photos_album_created_idx ON photos (album_id, created_at DESC, id DESC);
CREATE INDEX photos_deleted_idx ON photos (deleted_at) WHERE deleted_at IS NOT NULL;
CREATE TABLE album_members (
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
invited_by TEXT REFERENCES users(id) ON DELETE SET NULL,
joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (album_id, user_id)
);
CREATE TABLE invites (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE comments (
id TEXT PRIMARY KEY,
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX comments_photo_idx ON comments (photo_id, created_at);
CREATE TABLE likes (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (photo_id, user_id)
);
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
read_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX notifications_user_idx ON notifications (user_id, created_at DESC);
CREATE TABLE share_links (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX refresh_tokens_user_idx ON refresh_tokens (user_id);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX tags_user_name_idx ON tags (user_id, lower(name));
CREATE TABLE photo_tags (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (photo_id, tag_id)
);
CREATE INDEX photo_tags_tag_idx ON photo_tags (tag_id);
CREATE TABLE objects (
id TEXT PRIMARY KEY,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
checksum TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'ready', 'deleting')),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX objects_state_created_idx ON objects (state, created_at);
CREATE TABLE photo_objects (
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
PRIMARY KEY (photo_id, role)
);
CREATE INDEX photo_objects_object_idx ON photo_objects (object_id);
CREATE TABLE variant_jobs (
photo_id TEXT PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX variant_jobs_due_idx ON variant_jobs (next_attempt_at);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS app_settings;
@@ -1,6 +0,0 @@
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

Some files were not shown because too many files have changed in this diff Show More