diff --git a/.env.example b/.env.example index 3ed5814..206ac7a 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ 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_ENDPOINT=http://s3.localnet:3900 S3_REGION=garage @@ -14,11 +19,11 @@ S3_SECRET_KEY=change_me S3_BUCKET=galo-data S3_USE_PATH_STYLE=true -# Storage backend: s3 (default, S3-compatible/Garage) or fs (local filesystem). +# Storage backend: fs (default, local filesystem) or s3 (S3-compatible/Garage). # Blobs are always delivered through the backend via /api/objects/. -STORAGE_BACKEND=s3 -# Required when STORAGE_BACKEND=fs: root directory for object blobs -FS_ROOT=/var/lib/galo/objects +STORAGE_BACKEND=fs +# Root directory for object blobs (STORAGE_BACKEND=fs) +FS_ROOT=./data # Signs object URLs; falls back to JWT_SECRET when empty URL_SECRET= # Lifetime of signed object URLs diff --git a/.gitignore b/.gitignore index c81bf72..404e37c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ frontend/build/ frontend/.svelte-kit/ node_modules/ *.log + +# Local data backends (defaults) +galo.db +galo.db-* +data/ diff --git a/Makefile b/Makefile index fa97749..28e72cc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build frontend backend run migrate tidy test clean +.PHONY: build frontend backend run migrate reconcile reconcile-delete tidy test clean frontend: cd frontend && npm ci && npm run build @@ -20,6 +20,15 @@ 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 diff --git a/README.md b/README.md index 94bae5e..418e17d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,15 @@ cp .env.example .env `garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, `SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. +Слои данных выбираются параметрами (по умолчанию — локальный режим): + +- `STORAGE_BACKEND=fs|s3` — где лежат блобы: `fs` (по умолчанию, `FS_ROOT=./data`) + или `s3` (S3/Garage); наружу всегда отдаётся бэкенд (`/api/objects/`). +- `DB_BACKEND=sqlite|postgres` — где лежат метаданные: `sqlite` (по умолчанию, + `SQLITE_PATH=./galo.db`) или `postgres`. SQLite — встроенный режим без внешней БД, + **только для одного экземпляра приложения на файл** (без горизонтального + масштабирования). + ## Сборка ```bash @@ -94,6 +103,21 @@ 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`. @@ -130,6 +154,7 @@ backend/ internal/config # чтение env internal/db # pgx + golang-migrate internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд + internal/store # фабрика репозиториев (postgres / sqlite) internal/auth # JWT, bcrypt, refresh internal/albums # альбомы internal/members # участники, роли, инвайты diff --git a/backend/cmd/reconcile/main.go b/backend/cmd/reconcile/main.go new file mode 100644 index 0000000..7921a2b --- /dev/null +++ b/backend/cmd/reconcile/main.go @@ -0,0 +1,162 @@ +// 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/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() + + blob, err := storage.Open(ctx, cfg) + 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) + } + } +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e97ba86..f580283 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "shared-album/internal/db" "shared-album/internal/sse" "shared-album/internal/storage" + "shared-album/internal/store" ) func main() { @@ -37,37 +38,65 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() - migFS, err := fs.Sub(assets.Migrations, "migrations") - if err != nil { - slog.Error("migrations fs", "error", err) - os.Exit(1) + 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 } - if err := db.Migrate(cfg.Postgres.DSN(), migFS); err != nil { - slog.Error("migration failed", "error", err) - os.Exit(1) - } - slog.Info("migrations applied") + defer closer() + slog.Info("migrations applied", "db_backend", cfg.Database.Backend) - 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.Open(ctx, cfg) + blob, err := storage.Open(ctx, cfg) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) } - if err := store.EnsureBucket(ctx); err != nil { + if err := blob.EnsureBucket(ctx); err != nil { slog.Warn("could not ensure bucket", "error", err) } else { - slog.Info("storage bucket ready", "bucket", store.Bucket()) + slog.Info("storage ready", "location", blob.Bucket()) } hub := sse.NewHub(cfg.SSE.ClientBuffer) - server := app.New(cfg, pool, store, hub) + server := app.New(cfg, repos, ping, blob, hub) go server.StartWorker(ctx) diff --git a/backend/embed.go b/backend/embed.go index 5869c07..6690571 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -7,7 +7,7 @@ import "embed" //go:embed all:static var Static embed.FS -// Migrations holds the SQL migration files. +// Migrations holds the SQL migration files for every supported backend. // -//go:embed migrations/*.sql +//go:embed migrations/*.sql migrations/sqlite/*.sql var Migrations embed.FS diff --git a/backend/go.mod b/backend/go.mod index 119006b..4ca3177 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -17,6 +17,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 golang.org/x/crypto v0.58.0 + modernc.org/sqlite v1.60.1 ) require ( @@ -39,6 +40,7 @@ require ( 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 @@ -56,12 +58,14 @@ 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.20 // indirect + github.com/mattn/go-isatty v0.0.24 // 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 @@ -73,4 +77,7 @@ require ( 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 ) diff --git a/backend/go.sum b/backend/go.sum index 3e61305..0f5e896 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -85,6 +85,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV 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= @@ -129,8 +131,12 @@ github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgR 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= @@ -155,8 +161,8 @@ 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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/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= @@ -172,6 +178,8 @@ 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= @@ -187,6 +195,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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -226,6 +236,8 @@ 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= @@ -241,7 +253,6 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w 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= @@ -250,6 +261,8 @@ 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= @@ -262,3 +275,31 @@ 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= diff --git a/backend/internal/albums/repository.go b/backend/internal/albums/repository.go new file mode 100644 index 0000000..e22c98c --- /dev/null +++ b/backend/internal/albums/repository.go @@ -0,0 +1,18 @@ +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 +} diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 5bd1fd3..57ac51b 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -18,13 +18,13 @@ type MemberLookup interface { } type Service struct { - store *Store + store Repository members MemberLookup hub *sse.Hub storage storage.Backend } -func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service { +func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service { return &Service{store: store, members: members, hub: hub, storage: storage} } diff --git a/backend/internal/albums/sqlite_store.go b/backend/internal/albums/sqlite_store.go new file mode 100644 index 0000000..c8bf624 --- /dev/null +++ b/backend/internal/albums/sqlite_store.go @@ -0,0 +1,142 @@ +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 +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 325805a..ffe07b6 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -9,7 +9,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" assets "shared-album" "shared-album/internal/albums" @@ -23,16 +22,17 @@ import ( "shared-album/internal/social" "shared-album/internal/sse" "shared-album/internal/storage" + "shared-album/internal/store" ) type Server struct { cfg *config.Config - pool *pgxpool.Pool + repos store.Repos + ping func(ctx context.Context) error storage storage.Backend hub *sse.Hub - albumsStore *albums.Store - albums *albums.Service + albums *albums.Service auth *auth.Service @@ -48,36 +48,30 @@ type Server struct { share *sharelinks.Service } -func New(cfg *config.Config, pool *pgxpool.Pool, st storage.Backend, hub *sse.Hub) *Server { - authStore := auth.NewStore(pool) +// 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, st storage.Backend, hub *sse.Hub) *Server { tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL) - authSvc := auth.NewService(authStore, tm) + authSvc := auth.NewService(repos.Auth, tm) - albumsStore := albums.NewStore(pool) - membersStore := members.NewStore(pool) + notifSvc := notifications.NewService(repos.Notifications, hub) - notifStore := notifications.NewStore(pool) - notifSvc := notifications.NewService(notifStore, hub) + albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st) + membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc) - albumsSvc := albums.NewService(albumsStore, membersStore, hub, st) - membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc) - - photosStore := photos.NewStore(pool) - photosSvc := photos.NewService(photosStore, albumsSvc, st, hub) + photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub) photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL) - socialStore := social.NewStore(pool) - socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc) + socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc) - shareStore := sharelinks.NewStore(pool) - shareSvc := sharelinks.NewService(shareStore, albumsSvc) + shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc) return &Server{ cfg: cfg, - pool: pool, + repos: repos, + ping: ping, storage: st, hub: hub, - albumsStore: albumsStore, albums: albumsSvc, auth: authSvc, membersSvc: membersSvc, @@ -183,7 +177,7 @@ func (s *Server) Router() *gin.Engine { func (s *Server) health(c *gin.Context) { ctx := c.Request.Context() dbStatus := "ok" - if err := s.pool.Ping(ctx); err != nil { + if err := s.ping(ctx); err != nil { dbStatus = "error" } storageLabel := s.cfg.Storage.Backend @@ -243,7 +237,7 @@ func (s *Server) shareAlbum(c *gin.Context) { httpx.RespondError(c, err) return } - album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID) + album, err := s.repos.Albums.GetByID(c.Request.Context(), link.AlbumID) if err != nil { httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found")) return diff --git a/backend/internal/app/health_test.go b/backend/internal/app/health_test.go index f79c4b1..4b934a5 100644 --- a/backend/internal/app/health_test.go +++ b/backend/internal/app/health_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" "shared-album/internal/config" "shared-album/internal/storage" @@ -24,23 +23,14 @@ func mustSigner(t *testing.T) *storage.Signer { return signer } -func lazyPool(t *testing.T) *pgxpool.Pool { - t.Helper() - pool, err := pgxpool.New(context.Background(), "postgres://u:p@127.0.0.1:1/x?sslmode=disable") - if err != nil { - t.Fatalf("pool: %v", err) - } - t.Cleanup(pool.Close) - return pool -} - 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) } - srv := &Server{cfg: &config.Config{}, pool: lazyPool(t), storage: fs} + ok := func(context.Context) error { return nil } + srv := &Server{cfg: &config.Config{}, ping: ok, storage: fs} call := func() map[string]any { rec := httptest.NewRecorder() diff --git a/backend/internal/app/objects_test.go b/backend/internal/app/objects_test.go index db63f67..e107b24 100644 --- a/backend/internal/app/objects_test.go +++ b/backend/internal/app/objects_test.go @@ -15,6 +15,7 @@ import ( "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) { @@ -28,7 +29,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { if err != nil { t.Fatalf("new fs: %v", err) } - srv := New(&config.Config{}, nil, fs, sse.NewHub(1)) + noop := func(context.Context) error { return nil } + srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1)) return fs, srv.Router() } diff --git a/backend/internal/app/share_flow_test.go b/backend/internal/app/share_flow_test.go index 1d5b0b3..41bdb3f 100644 --- a/backend/internal/app/share_flow_test.go +++ b/backend/internal/app/share_flow_test.go @@ -27,6 +27,7 @@ import ( "shared-album/internal/sharelinks" "shared-album/internal/sse" "shared-album/internal/storage" + "shared-album/internal/store" ) func integrationPool(t *testing.T) *pgxpool.Pool { @@ -70,7 +71,7 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing. gin.SetMode(gin.TestMode) hub := sse.NewHub(1) - srv := New(&config.Config{}, pool, backend, hub) + srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub) router := srv.Router() authStore := auth.NewStore(pool) diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go new file mode 100644 index 0000000..dcd75bc --- /dev/null +++ b/backend/internal/auth/repository.go @@ -0,0 +1,19 @@ +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 +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go index db2b030..0453f79 100644 --- a/backend/internal/auth/service.go +++ b/backend/internal/auth/service.go @@ -18,11 +18,11 @@ type TokenPair struct { } type Service struct { - store *Store + store Repository tm *TokenManager } -func NewService(store *Store, tm *TokenManager) *Service { +func NewService(store Repository, tm *TokenManager) *Service { return &Service{store: store, tm: tm} } diff --git a/backend/internal/auth/sqlite_store.go b/backend/internal/auth/sqlite_store.go new file mode 100644 index 0000000..07b5312 --- /dev/null +++ b/backend/internal/auth/sqlite_store.go @@ -0,0 +1,111 @@ +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 +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index cc9c135..20f62c0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -59,10 +59,16 @@ type Storage struct { 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 @@ -138,11 +144,15 @@ func Load() (*Config, error) { UsePathStyle: pathStyle, }, Storage: Storage{ - Backend: getEnv("STORAGE_BACKEND", "s3"), - FSRoot: getEnv("FS_ROOT", ""), + 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"), + }, Auth: Auth{ JWTSecret: getEnv("JWT_SECRET", ""), AccessTTL: accessTTL, @@ -166,8 +176,18 @@ func Load() (*Config, error) { if cfg.Auth.JWTSecret == "" { return nil, fmt.Errorf("JWT_SECRET is required") } - if cfg.Postgres.Password == "" { - return nil, fmt.Errorf("PG_PASSWORD is required") + + 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) } switch cfg.Storage.Backend { diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 684e49d..398d66d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -12,15 +12,15 @@ func setRequiredEnv(t *testing.T) { } func TestLoadStorageBackend(t *testing.T) { - t.Run("defaults to s3", func(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 != "s3" { - t.Fatalf("backend = %q, want s3", cfg.Storage.Backend) + if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "./data" { + t.Fatalf("storage = %+v", cfg.Storage) } }) @@ -37,13 +37,17 @@ func TestLoadStorageBackend(t *testing.T) { } }) - t.Run("fs without root", func(t *testing.T) { + 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", "") - if _, err := Load(); err == nil { - t.Fatal("expected error for fs without FS_ROOT") + 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) } }) @@ -68,3 +72,63 @@ func TestLoadStorageBackend(t *testing.T) { } }) } + +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") + } + }) +} diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go index bdd3404..55e2584 100644 --- a/backend/internal/db/migrate.go +++ b/backend/internal/db/migrate.go @@ -8,8 +8,10 @@ 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). @@ -39,3 +41,32 @@ 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 +} diff --git a/backend/internal/db/migrate_sqlite_test.go b/backend/internal/db/migrate_sqlite_test.go new file mode 100644 index 0000000..c6387d9 --- /dev/null +++ b/backend/internal/db/migrate_sqlite_test.go @@ -0,0 +1,37 @@ +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") + } +} diff --git a/backend/internal/db/sqlite.go b/backend/internal/db/sqlite.go new file mode 100644 index 0000000..e429298 --- /dev/null +++ b/backend/internal/db/sqlite.go @@ -0,0 +1,31 @@ +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 +} diff --git a/backend/internal/db/sqlite_test.go b/backend/internal/db/sqlite_test.go new file mode 100644 index 0000000..2c790bd --- /dev/null +++ b/backend/internal/db/sqlite_test.go @@ -0,0 +1,45 @@ +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) + } +} diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go new file mode 100644 index 0000000..a040cbe --- /dev/null +++ b/backend/internal/e2e/sqlite_test.go @@ -0,0 +1,150 @@ +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/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 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) +} diff --git a/backend/internal/members/repository.go b/backend/internal/members/repository.go new file mode 100644 index 0000000..b700c99 --- /dev/null +++ b/backend/internal/members/repository.go @@ -0,0 +1,19 @@ +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 +} diff --git a/backend/internal/members/service.go b/backend/internal/members/service.go index e0a454b..361725f 100644 --- a/backend/internal/members/service.go +++ b/backend/internal/members/service.go @@ -21,13 +21,13 @@ type Notifier interface { } type Service struct { - store *Store + store Repository albums AlbumReader hub *sse.Hub notify Notifier } -func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service { +func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service { return &Service{store: store, albums: albums, hub: hub, notify: notify} } diff --git a/backend/internal/members/sqlite_store.go b/backend/internal/members/sqlite_store.go new file mode 100644 index 0000000..76771df --- /dev/null +++ b/backend/internal/members/sqlite_store.go @@ -0,0 +1,133 @@ +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 +} diff --git a/backend/internal/notifications/repository.go b/backend/internal/notifications/repository.go new file mode 100644 index 0000000..c69d9db --- /dev/null +++ b/backend/internal/notifications/repository.go @@ -0,0 +1,14 @@ +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 +} diff --git a/backend/internal/notifications/service.go b/backend/internal/notifications/service.go index 7185d56..1abf9b2 100644 --- a/backend/internal/notifications/service.go +++ b/backend/internal/notifications/service.go @@ -10,11 +10,11 @@ import ( ) type Service struct { - store *Store + store Repository hub *sse.Hub } -func NewService(store *Store, hub *sse.Hub) *Service { +func NewService(store Repository, hub *sse.Hub) *Service { return &Service{store: store, hub: hub} } diff --git a/backend/internal/notifications/sqlite_store.go b/backend/internal/notifications/sqlite_store.go new file mode 100644 index 0000000..061bdb9 --- /dev/null +++ b/backend/internal/notifications/sqlite_store.go @@ -0,0 +1,88 @@ +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 +} diff --git a/backend/internal/photos/objects.go b/backend/internal/photos/objects.go index 36ce908..f28cfff 100644 --- a/backend/internal/photos/objects.go +++ b/backend/internal/photos/objects.go @@ -368,3 +368,21 @@ func (s *Store) FailVariantJob(ctx context.Context, photoID, errMsg string, maxA } return tx.Commit(ctx) } + +// AllStorageKeys returns every key registered in the object journal. +func (s *Store) AllStorageKeys(ctx context.Context) ([]string, error) { + rows, err := s.pool.Query(ctx, `SELECT storage_key FROM objects`) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} diff --git a/backend/internal/photos/repository.go b/backend/internal/photos/repository.go new file mode 100644 index 0000000..48d56e2 --- /dev/null +++ b/backend/internal/photos/repository.go @@ -0,0 +1,52 @@ +package photos + +import ( + "context" + "time" + + "shared-album/internal/models" +) + +// Repository is the persistence contract for the photos domain, including the +// object ledger, variant queue and garbage collection. +type Repository interface { + CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error + DeleteUpload(ctx context.Context, photoID, objectID string) error + GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) + List(ctx context.Context, in ListParams) ([]models.Photo, error) + SetDeleted(ctx context.Context, id string, deleted bool) error + UpdateAlbum(ctx context.Context, id, albumID string) error + Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error + HardDelete(ctx context.Context, id string) error + ListTrash(ctx context.Context, userID string) ([]models.Photo, error) + TrashCount(ctx context.Context, userID string) (int, error) + RestoreToAlbum(ctx context.Context, id, albumID string) error + AlbumExists(ctx context.Context, albumID string) (bool, error) + ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) + EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) + AttachTag(ctx context.Context, photoID, tagID string) error + DetachTag(ctx context.Context, photoID, tagID string) error + TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) + SetCoverIfEmpty(ctx context.Context, albumID, photoID string) + UpdateFilename(ctx context.Context, id, filename string) error + BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) + + CreateObject(ctx context.Context, o *Object) error + SetObjectState(ctx context.Context, id, state string) error + ActivateObject(ctx context.Context, id string) error + MarkObjectDeleting(ctx context.Context, id string) error + DeleteObjectRow(ctx context.Context, id string) error + BindObject(ctx context.Context, photoID, objectID, role string) error + PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) + ObjectRefCount(ctx context.Context, objectID string) (int, error) + OriginalObject(ctx context.Context, photoID string) (*Object, error) + PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) + ClaimGarbage(ctx context.Context, limit int) ([]Object, error) + ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) + ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) + CompleteVariantJob(ctx context.Context, photoID string) error + FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error + + // AllStorageKeys returns every key registered in the object journal. + AllStorageKeys(ctx context.Context) ([]string, error) +} diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 345b72b..d5191d8 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -22,13 +22,13 @@ type AlbumAccess interface { } type Service struct { - store *Store + store Repository albums AlbumAccess storage storage.Backend hub *sse.Hub } -func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service { +func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service { return &Service{store: store, albums: albums, storage: storage, hub: hub} } diff --git a/backend/internal/photos/sqlite_helpers.go b/backend/internal/photos/sqlite_helpers.go new file mode 100644 index 0000000..02190fc --- /dev/null +++ b/backend/internal/photos/sqlite_helpers.go @@ -0,0 +1,30 @@ +package photos + +import ( + "time" + + "shared-album/internal/sqliteutil" +) + +func nullIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func nullTime(t *time.Time) any { + if t == nil { + return nil + } + return sqliteutil.FormatTime(*t) +} + +func nullFloat(f *float64) any { + if f == nil { + return nil + } + return *f +} + +func nowUTC() time.Time { return time.Now().UTC() } diff --git a/backend/internal/photos/sqlite_objects.go b/backend/internal/photos/sqlite_objects.go new file mode 100644 index 0000000..54b1722 --- /dev/null +++ b/backend/internal/photos/sqlite_objects.go @@ -0,0 +1,365 @@ +package photos + +import ( + "context" + "database/sql" + "errors" + "time" + + "shared-album/internal/sqliteutil" +) + +// CreateObject inserts a new object row in the pending state. +func (s *SQLiteStore) CreateObject(ctx context.Context, o *Object) error { + if o.State == "" { + o.State = ObjectPending + } + var createdAt string + err := s.db.QueryRowContext(ctx, + `INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING created_at`, + o.ID, o.StorageKey, o.MimeType, o.Size, o.Width, o.Height, o.Checksum, o.State).Scan(&createdAt) + if err != nil { + return err + } + o.CreatedAt = sqliteutil.ParseTime(createdAt) + return nil +} + +// SetObjectState updates an object's lifecycle state. +func (s *SQLiteStore) SetObjectState(ctx context.Context, id, state string) error { + res, err := s.db.ExecContext(ctx, `UPDATE objects SET state = $2 WHERE id = $1`, id, state) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrObjectNotFound + } + return nil +} + +// ActivateObject marks an object ready after its bytes have been stored. +func (s *SQLiteStore) ActivateObject(ctx context.Context, id string) error { + return s.SetObjectState(ctx, id, ObjectReady) +} + +// MarkObjectDeleting marks an object as pending removal from storage. +func (s *SQLiteStore) MarkObjectDeleting(ctx context.Context, id string) error { + return s.SetObjectState(ctx, id, ObjectDeleting) +} + +// DeleteObjectRow permanently removes an object row (after its blob is gone). +func (s *SQLiteStore) DeleteObjectRow(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrObjectNotFound + } + return nil +} + +// BindObject attaches a ready object to a photo under the given role. The write +// lock of the immediate transaction serializes it against garbage collection. +func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var state string + if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrObjectNotFound + } + return err + } + if state != ObjectReady { + return ErrObjectNotReady + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3) + ON CONFLICT (photo_id, role) DO UPDATE SET object_id = excluded.object_id`, + photoID, objectID, role); err != nil { + return err + } + return tx.Commit() +} + +// PhotoObjectIDs returns the object ids currently bound to a photo. +func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// ObjectRefCount counts how many photo bindings reference an object. +func (s *SQLiteStore) ObjectRefCount(ctx context.Context, objectID string) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM photo_objects WHERE object_id = $1`, objectID).Scan(&n) + return n, err +} + +// OriginalObject returns the original object bound to a photo. +func (s *SQLiteStore) OriginalObject(ctx context.Context, photoID string) (*Object, error) { + var o Object + var createdAt string + err := s.db.QueryRowContext(ctx, + `SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at + FROM photo_objects po JOIN objects o ON o.id = po.object_id + WHERE po.photo_id = $1 AND po.role = $2`, photoID, RoleOriginal). + Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrObjectNotFound + } + return nil, err + } + o.CreatedAt = sqliteutil.ParseTime(createdAt) + return &o, nil +} + +// PhotosByOriginalObject returns the ids of all photos bound to the given +// original object, so generated variants can be shared between copies. +func (s *SQLiteStore) PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT photo_id FROM photo_objects WHERE object_id = $1 AND role = $2`, originalObjectID, RoleOriginal) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +// ClaimGarbage claims collectible objects: ready objects with no bindings and +// objects already marked deleting. +func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) { + if limit <= 0 { + limit = 10 + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + rows, err := tx.QueryContext(ctx, + `SELECT o.id, o.storage_key FROM objects o + WHERE o.state IN ('ready', 'deleting') + AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id) + ORDER BY o.created_at + LIMIT $1`, limit) + if err != nil { + return nil, err + } + objs, err := scanObjectKeys(rows) + if err != nil { + return nil, err + } + if len(objs) == 0 { + return nil, tx.Commit() + } + if err := markDeleting(ctx, tx, objs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return objs, nil +} + +// ClaimStalePending marks abandoned pending objects as deleting and returns them. +func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) { + if limit <= 0 { + limit = 10 + } + threshold := sqliteutil.FormatTime(time.Now().UTC().Add(-olderThan)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + rows, err := tx.QueryContext(ctx, + `SELECT id, storage_key FROM objects + WHERE state = 'pending' AND created_at < $1 + ORDER BY created_at + LIMIT $2`, threshold, limit) + if err != nil { + return nil, err + } + objs, err := scanObjectKeys(rows) + if err != nil { + return nil, err + } + if len(objs) == 0 { + return nil, tx.Commit() + } + if err := markDeleting(ctx, tx, objs); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return objs, nil +} + +// ClaimVariantJobs leases due variant jobs and returns their photo ids. +func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) { + if limit <= 0 { + limit = 10 + } + now := sqliteutil.FormatTime(time.Now().UTC()) + next := sqliteutil.FormatTime(time.Now().UTC().Add(lease)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + rows, err := tx.QueryContext(ctx, + `SELECT photo_id FROM variant_jobs + WHERE next_attempt_at <= $1 + ORDER BY next_attempt_at + LIMIT $2`, now, limit) + if err != nil { + return nil, err + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return nil, err + } + ids = append(ids, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, tx.Commit() + } + args := make([]any, 0, len(ids)+1) + args = append(args, next) + for _, id := range ids { + args = append(args, id) + } + if _, err := tx.ExecContext(ctx, + `UPDATE variant_jobs SET next_attempt_at = $1 WHERE photo_id IN (`+sqliteutil.Placeholders(2, len(ids))+`)`, + args...); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return ids, nil +} + +// CompleteVariantJob removes a finished job. +func (s *SQLiteStore) CompleteVariantJob(ctx context.Context, photoID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID) + return err +} + +// FailVariantJob records a failure and schedules a retry with exponential +// backoff, abandoning the job after maxAttempts. +func (s *SQLiteStore) FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + var attempts int + err = tx.QueryRowContext(ctx, `SELECT attempts FROM variant_jobs WHERE photo_id = $1`, photoID).Scan(&attempts) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return tx.Commit() + } + return err + } + newAttempts := attempts + 1 + if maxAttempts > 0 && newAttempts >= maxAttempts { + if _, err := tx.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID); err != nil { + return err + } + } else { + secs := 10 * (1 << attempts) + if secs > 300 { + secs = 300 + } + next := sqliteutil.FormatTime(time.Now().UTC().Add(time.Duration(secs) * time.Second)) + if _, err := tx.ExecContext(ctx, + `UPDATE variant_jobs SET attempts = $2, next_attempt_at = $3, last_error = $4 WHERE photo_id = $1`, + photoID, newAttempts, next, errMsg); err != nil { + return err + } + } + return tx.Commit() +} + +func scanObjectKeys(rows *sql.Rows) ([]Object, error) { + var objs []Object + for rows.Next() { + var o Object + if err := rows.Scan(&o.ID, &o.StorageKey); err != nil { + rows.Close() + return nil, err + } + objs = append(objs, o) + } + rows.Close() + return objs, rows.Err() +} + +func markDeleting(ctx context.Context, tx *sql.Tx, objs []Object) error { + args := make([]any, len(objs)) + for i, o := range objs { + args[i] = o.ID + } + _, err := tx.ExecContext(ctx, + `UPDATE objects SET state = 'deleting' WHERE id IN (`+sqliteutil.Placeholders(1, len(objs))+`)`, args...) + return err +} + +// AllStorageKeys returns every key registered in the object journal. +func (s *SQLiteStore) AllStorageKeys(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT storage_key FROM objects`) + if err != nil { + return nil, err + } + defer rows.Close() + var keys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go new file mode 100644 index 0000000..1af97bd --- /dev/null +++ b/backend/internal/photos/sqlite_store.go @@ -0,0 +1,470 @@ +package photos + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + + "shared-album/internal/auth" + "shared-album/internal/models" + "shared-album/internal/sqliteutil" +) + +// SQLiteStore is the SQLite implementation of Repository. +type SQLiteStore struct { + db *sql.DB +} + +func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} } + +const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, + COALESCE(o_o.storage_key, ''), + COALESCE(o_t.storage_key, ''), + COALESCE(o_p.storage_key, ''), + p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''), + p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, + u.id, u.name, COALESCE(u.avatar_url, ''), + (SELECT count(*) FROM likes l WHERE l.photo_id = p.id), + (SELECT count(*) FROM comments c WHERE c.photo_id = p.id), + EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2), + COALESCE(p.exif, '{}'), p.deleted_at, + COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name)) + FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id + WHERE pt.photo_id = p.id AND t.user_id = $2 + ORDER BY lower(t.name)) t), '[]')` + +const sqlitePhotoJoins = ` + LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original' + LEFT JOIN objects o_o ON o_o.id = po_o.object_id + LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb' + LEFT JOIN objects o_t ON o_t.id = po_t.object_id + LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview' + LEFT JOIN objects o_p ON o_p.id = po_p.object_id` + +func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) { + var p models.Photo + var exifText, tagsText, createdAt string + var takenAt, deletedAt sql.NullString + var lat, long sql.NullFloat64 + var liked int + if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, + &p.ThumbKey, &p.PreviewKey, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &takenAt, &lat, &long, &p.Caption, &createdAt, + &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, + &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil { + return nil, err + } + p.TakenAt = sqliteutil.ParseNullTime(takenAt) + if lat.Valid { + p.Latitude = &lat.Float64 + } + if long.Valid { + p.Longitude = &long.Float64 + } + p.CreatedAt = sqliteutil.ParseTime(createdAt) + p.DeletedAt = sqliteutil.ParseNullTime(deletedAt) + p.LikedByMe = liked != 0 + setExif(&p, exifText) + setTags(&p, tagsText) + return &p, nil +} + +func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error { + exifJSON, err := json.Marshal(p.EXIF) + if err != nil || len(exifJSON) == 0 { + exifJSON = []byte("{}") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil { + return err + } + var createdAt string + if err := tx.QueryRowContext(ctx, + `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING created_at`, + p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename, + nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON), + ).Scan(&createdAt); err != nil { + return err + } + p.CreatedAt = sqliteutil.ParseTime(createdAt) + if _, err := tx.ExecContext(ctx, + `INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`, + p.ID, obj.ID, RoleOriginal); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) { + row := s.db.QueryRowContext(ctx, + `SELECT `+sqlitePhotoColumns+` + FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+` + WHERE p.id = $1`, id, viewerID) + p, err := scanSQLitePhoto(row.Scan) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return p, nil +} + +func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) { + if in.Limit <= 0 || in.Limit > 100 { + in.Limit = 50 + } + var since any + if in.Since != nil { + since = sqliteutil.FormatTime(*in.Since) + } + rows, err := s.db.QueryContext(ctx, + `SELECT `+sqlitePhotoColumns+` + FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+` + WHERE p.album_id = $1 + AND p.deleted_at IS NULL + AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3)) + AND ($4 IS NULL OR p.created_at > $4) + ORDER BY p.created_at DESC, p.id DESC + LIMIT $5`, + in.AlbumID, in.Viewer, in.Cursor, since, in.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.Photo{} + for rows.Next() { + p, err := scanSQLitePhoto(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error { + var res sql.Result + var err error + if deleted { + res, err = s.db.ExecContext(ctx, + `UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`, + id, sqliteutil.FormatTime(nowUTC())) + } else { + res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id) + } + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error { + res, err := s.db.ExecContext(ctx, + `UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error { + exifJSON, err := json.Marshal(src.EXIF) + if err != nil || len(exifJSON) == 0 { + exifJSON = []byte("{}") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename, + nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO photo_objects (photo_id, object_id, role) + SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`, + id, src.ID); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '') + FROM photos p + JOIN users u ON u.id = p.uploader_id + LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+` + WHERE p.deleted_at IS NOT NULL + AND ( + (a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS( + SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))) + OR (p.album_id IS NULL AND p.uploader_id = $1) + ) + ORDER BY p.deleted_at DESC + LIMIT 200`, + userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.Photo{} + for rows.Next() { + var p models.Photo + var exifText, tagsText, createdAt, albumTitle string + var takenAt, deletedAt sql.NullString + var lat, long sql.NullFloat64 + var liked int + if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey, + &p.ThumbKey, &p.PreviewKey, + &p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType, + &takenAt, &lat, &long, &p.Caption, &createdAt, + &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, + &p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil { + return nil, err + } + p.TakenAt = sqliteutil.ParseNullTime(takenAt) + if lat.Valid { + p.Latitude = &lat.Float64 + } + if long.Valid { + p.Longitude = &long.Float64 + } + p.CreatedAt = sqliteutil.ParseTime(createdAt) + p.DeletedAt = sqliteutil.ParseNullTime(deletedAt) + p.AlbumTitle = albumTitle + p.LikedByMe = liked != 0 + setExif(&p, exifText) + setTags(&p, tagsText) + out = append(out, p) + } + return out, rows.Err() +} + +func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT count(*) FROM photos p + LEFT JOIN albums a ON a.id = p.album_id + WHERE p.deleted_at IS NOT NULL + AND ( + (a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS( + SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1))) + OR (p.album_id IS NULL AND p.uploader_id = $1) + )`, userID).Scan(&n) + return n, err +} + +func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error { + res, err := s.db.ExecContext(ctx, + `UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) { + var ok int + err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok) + return ok != 0, err +} + +func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name FROM tags + WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%') + ORDER BY lower(name) LIMIT 50`, userID, q) + if err != nil { + return nil, err + } + defer rows.Close() + out := []models.Tag{} + for rows.Next() { + var t models.Tag + if err := rows.Scan(&t.ID, &t.Name); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) { + name = strings.TrimSpace(name) + if name == "" || len(name) > 64 { + return nil, ErrNotFound + } + var t models.Tag + err := s.db.QueryRowContext(ctx, + `SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name). + Scan(&t.ID, &t.Name) + if err == nil { + return &t, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + id := auth.NewID() + err = s.db.QueryRowContext(ctx, + `INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name). + Scan(&t.ID, &t.Name) + if err != nil { + if e2 := s.db.QueryRowContext(ctx, + `SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name). + Scan(&t.ID, &t.Name); e2 == nil { + return &t, nil + } + return nil, err + } + return &t, nil +} + +func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID) + return err +} + +func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID) + return err +} + +func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) { + var ok int + err := s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok) + return ok != 0, err +} + +func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) { + _, _ = s.db.ExecContext(ctx, + `UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`, + albumID, photoID) +} + +func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error { + res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob +// of every photo with empty exif. +func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT p.id, o.storage_key + FROM photos p + JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original' + JOIN objects o ON o.id = po.object_id + WHERE p.exif IS NULL OR p.exif = '{}' + ORDER BY p.created_at`) + if err != nil { + return 0, err + } + var pending []backfillRow + for rows.Next() { + var r backfillRow + if err := rows.Scan(&r.ID, &r.StorageKey); err != nil { + rows.Close() + return 0, err + } + pending = append(pending, r) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + updated := 0 + for _, r := range pending { + data, err := fetch(ctx, r.StorageKey) + if err != nil { + continue + } + meta := extractEXIF(data) + if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil { + continue + } + attrs, err := json.Marshal(meta.Attributes) + if err != nil || len(attrs) == 0 { + attrs = []byte("{}") + } + if _, err := s.db.ExecContext(ctx, + `UPDATE photos SET exif = $2, + taken_at = COALESCE(taken_at, $3), + latitude = COALESCE(latitude, $4), + longitude = COALESCE(longitude, $5) + WHERE id = $1`, + r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil { + continue + } + updated++ + } + return updated, nil +} diff --git a/backend/internal/reconcile/reconcile.go b/backend/internal/reconcile/reconcile.go new file mode 100644 index 0000000..2782edf --- /dev/null +++ b/backend/internal/reconcile/reconcile.go @@ -0,0 +1,74 @@ +// 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 +} diff --git a/backend/internal/reconcile/reconcile_test.go b/backend/internal/reconcile/reconcile_test.go new file mode 100644 index 0000000..a06c9b8 --- /dev/null +++ b/backend/internal/reconcile/reconcile_test.go @@ -0,0 +1,41 @@ +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) + } +} diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go new file mode 100644 index 0000000..8261518 --- /dev/null +++ b/backend/internal/repo/contract/contract.go @@ -0,0 +1,533 @@ +// 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/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 + 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}, + } + 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 contains(list []string, v string) bool { + for _, x := range list { + if x == v { + return true + } + } + return false +} diff --git a/backend/internal/repo/contract/postgres_test.go b/backend/internal/repo/contract/postgres_test.go new file mode 100644 index 0000000..8f0f61e --- /dev/null +++ b/backend/internal/repo/contract/postgres_test.go @@ -0,0 +1,78 @@ +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/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), + Reset: func(ctx context.Context) error { + _, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`) + return err + }, + }) +} diff --git a/backend/internal/repo/contract/sqlite_test.go b/backend/internal/repo/contract/sqlite_test.go new file mode 100644 index 0000000..1d65985 --- /dev/null +++ b/backend/internal/repo/contract/sqlite_test.go @@ -0,0 +1,57 @@ +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/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), + 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", + } + for _, tbl := range tables { + if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil { + return err + } + } + return nil + }, + }) +} diff --git a/backend/internal/sharelinks/repository.go b/backend/internal/sharelinks/repository.go new file mode 100644 index 0000000..54b7702 --- /dev/null +++ b/backend/internal/sharelinks/repository.go @@ -0,0 +1,15 @@ +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 +} diff --git a/backend/internal/sharelinks/service.go b/backend/internal/sharelinks/service.go index fb92b45..13db847 100644 --- a/backend/internal/sharelinks/service.go +++ b/backend/internal/sharelinks/service.go @@ -16,11 +16,11 @@ type AlbumAccess interface { } type Service struct { - store *Store + store Repository albums AlbumAccess } -func NewService(store *Store, albums AlbumAccess) *Service { +func NewService(store Repository, albums AlbumAccess) *Service { return &Service{store: store, albums: albums} } diff --git a/backend/internal/sharelinks/sqlite_store.go b/backend/internal/sharelinks/sqlite_store.go new file mode 100644 index 0000000..15a04df --- /dev/null +++ b/backend/internal/sharelinks/sqlite_store.go @@ -0,0 +1,90 @@ +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 +} diff --git a/backend/internal/social/repository.go b/backend/internal/social/repository.go new file mode 100644 index 0000000..d1d48f7 --- /dev/null +++ b/backend/internal/social/repository.go @@ -0,0 +1,17 @@ +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 +} diff --git a/backend/internal/social/service.go b/backend/internal/social/service.go index 84fd934..3e7a0cd 100644 --- a/backend/internal/social/service.go +++ b/backend/internal/social/service.go @@ -25,14 +25,14 @@ type Notifier interface { } type Service struct { - store *Store + store Repository albums AlbumAccess photos PhotoLookup hub *sse.Hub notify Notifier } -func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service { +func NewService(store Repository, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service { return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify} } diff --git a/backend/internal/social/sqlite_store.go b/backend/internal/social/sqlite_store.go new file mode 100644 index 0000000..26ba01f --- /dev/null +++ b/backend/internal/social/sqlite_store.go @@ -0,0 +1,101 @@ +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 +} diff --git a/backend/internal/sqliteutil/sqliteutil.go b/backend/internal/sqliteutil/sqliteutil.go new file mode 100644 index 0000000..d79379e --- /dev/null +++ b/backend/internal/sqliteutil/sqliteutil.go @@ -0,0 +1,50 @@ +// 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, ",") +} diff --git a/backend/internal/storage/fs.go b/backend/internal/storage/fs.go index 83e8cc8..e8737c9 100644 --- a/backend/internal/storage/fs.go +++ b/backend/internal/storage/fs.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "io/fs" "mime" "net/http" "net/url" @@ -144,6 +145,37 @@ func (f *FS) URL(_ context.Context, key string) (string, error) { 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. diff --git a/backend/internal/storage/fs_test.go b/backend/internal/storage/fs_test.go index 1a4ce8f..2223608 100644 --- a/backend/internal/storage/fs_test.go +++ b/backend/internal/storage/fs_test.go @@ -172,3 +172,33 @@ func TestContentTypeForKey(t *testing.T) { } } } + +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) + } + } +} diff --git a/backend/internal/storage/s3_test.go b/backend/internal/storage/s3_test.go index 0e4bf4f..6a7f777 100644 --- a/backend/internal/storage/s3_test.go +++ b/backend/internal/storage/s3_test.go @@ -117,6 +117,33 @@ func TestS3ServeObject(t *testing.T) { }) } +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) diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 6cce96f..ec1f7fb 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -8,6 +8,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -29,6 +30,13 @@ 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 +} + // 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. @@ -41,6 +49,8 @@ type Backend interface { // 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. @@ -183,6 +193,36 @@ 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) + if err != nil { + return nil, 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), + }) + } + if res.IsTruncated == nil || !*res.IsTruncated { + return out, nil + } + token = res.NextContinuationToken + } +} + // 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. diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..085b4e1 --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,55 @@ +// 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/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 +} + +// 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), + } +} + +// 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), + } +} diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go new file mode 100644 index 0000000..6961d97 --- /dev/null +++ b/backend/internal/store/store_test.go @@ -0,0 +1,55 @@ +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)) +} diff --git a/backend/migrations/sqlite/000001_init.down.sql b/backend/migrations/sqlite/000001_init.down.sql new file mode 100644 index 0000000..77415ae --- /dev/null +++ b/backend/migrations/sqlite/000001_init.down.sql @@ -0,0 +1,15 @@ +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; diff --git a/backend/migrations/sqlite/000001_init.up.sql b/backend/migrations/sqlite/000001_init.up.sql new file mode 100644 index 0000000..955a01d --- /dev/null +++ b/backend/migrations/sqlite/000001_init.up.sql @@ -0,0 +1,159 @@ +-- 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); diff --git a/openspec/changes/sqlite-metadata-backend/.openspec.yaml b/openspec/changes/sqlite-metadata-backend/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/sqlite-metadata-backend/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/sqlite-metadata-backend/design.md b/openspec/changes/sqlite-metadata-backend/design.md new file mode 100644 index 0000000..6621dc4 --- /dev/null +++ b/openspec/changes/sqlite-metadata-backend/design.md @@ -0,0 +1,120 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее состояние, определяющее подход: + +- Каждый домен имеет конкретный `Store` с полем `*pgxpool.Pool` и SQL диалекта Postgres. + Всего ~72 метода: `auth(9)`, `albums(7)`, `members(8)`, `photos(20)`, + `photos/objects(15)`, `social(6)`, `notifications(3)`, `sharelinks(4)`. +- Postgres-специфика: `FOR UPDATE SKIP LOCKED`, `ANY($1)`, `make_interval`/`power`/`least`, + `now()`, `timestamptz`, `jsonb`/`::jsonb`, `ON CONFLICT`, `RETURNING`. +- Кросс-доменные зависимости уже выражены интерфейсами (`AlbumAccess`, `PhotoLookup`, + `Notifier`, `MemberLookup`, `AlbumReader`); свои store сервисы берут конкретными. +- Сборка `CGO_ENABLED=0`; model: один экземпляр приложения на инсталляцию. +- Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на + один экземпляр приложения. + +## Goals / Non-Goals + +**Goals:** +- Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом. +- Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику. +- Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах. +- Гарантировать паритет общим контрактным набором тестов. + +**Non-Goals:** +- Горизонтальное масштабирование и несколько экземпляров на один файл SQLite. +- Миграция данных между PostgreSQL и SQLite. +- Изменение наблюдаемого поведения API/спек (см. `proposal.md`, изменений требований нет). +- Изменение слоя хранения (см. `local-fs-storage-backend`). + +## Decisions + +### D1. Параллельные реализации, а не слой диалекта +Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные +SQLite-store. Интерфейс `Repository` становится швом между доменом и хранилищем. +- Альтернатива: единая логика + `Dialect` — отвергнута: правки во всех существующих + store и риск регрессии Postgres ради экономии дублирования. +- Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9). + +### D2. Именование и размещение +Интерфейс домена — `Repository`; существующий pgx-тип остаётся `Store` (`NewStore(pool)`), +SQLite-тип — `SQLiteStore` и живёт в том же пакете (`photos/sqlite_store.go` и т.п.). +- В том же пакете — доступ к `models` и sentinel-ошибкам без циклов импорта и без + перемещения существующего кода. +- Сервисы типизируются интерфейсом (`albums.Repository` и т.д.); кросс-доменные + интерфейсы удовлетворяются любой реализацией. + +### D3. Драйвер SQLite — `modernc.org/sqlite` +Чистый Go, совместим с `CGO_ENABLED=0`. +- Альтернатива `mattn/go-sqlite3` (CGO) — отвергнута, ломает модель сборки. + +### D4. Роль `$1`-плейсхолдеров +Плейсхолдеры остаются `$1`: и pgx, и `modernc` понимают именованные параметры. +Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен. + +### D5. Отдельный набор миграций для SQLite +`migrations/sqlite/` рядом с Postgres-набором, драйвер `golang-migrate/database/sqlite`. +Отображение типов: `timestamptz`→TEXT, `jsonb`→TEXT, `bigint`→INTEGER, +`double precision`→REAL, `now()`→`strftime('%Y-%m-%dT%H:%M:%fZ','now')`, +`REFERENCES ... ON DELETE CASCADE` и `CHECK` сохраняются. +- Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих. + +### D6. Настройка соединения и конкурентность +`journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON` через DSN-параметры +`_pragma`. Писатель один; читатели — параллельно. +- `ANY($1)` переписывается в `IN (?, ...)` с построением списка в Go; батчи малы. +- `FOR UPDATE`/`SKIP LOCKED` заменяются транзакцией с немедленной блокировкой записи + (`BEGIN IMMEDIATE`); порядок «проверить состояние — вставить/заявку» сохраняется. + +### D7. Атомарность жизненного цикла объектов +`BindObject` и claim-методы (`ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`) +выполняются в `BEGIN IMMEDIATE`-транзакции, что сериализует запись и воспроизводит +инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить +используемый; одинаковая роль без дублей). + +### D8. Backoff и `now()` +`FailVariantJob` считает задержку в Go (`min(300, 10*2^attempts)`) вместо +`make_interval/power/least`. Время пишется/читается через хелперы преобразования +`time.Time` <-> RFC3339 TEXT; запросы сравнения используют `datetime('now')`/значения Go, +а не неявное приведение драйвера. + +### D9. Контрактные тесты на интерфейс +Один набор тестов на каждый `Repository`, прогоняемый на обоих бэкендах: Postgres под +`TEST_DATABASE_URL` (пропуск без него), SQLite на временном файле (всегда). Это механизм +удержания паритета при параллельных реализациях. + +### D10. Фабрика и сборка зависимостей +`store.NewPostgres(pool)` и `store.NewSQLite(db)` возвращают бандл репозиториев; +`app.New` принимает бандл и `storage.Backend`. Выбор бэкенда — на старте по +`DB_BACKEND=postgres|sqlite` (дефолт `postgres`), путь — `SQLITE_PATH`. +- Альтернатива: ветвление внутри `app.New` по типу пула — отвергнута, размазывает выбор. + +### D11. Маппинг ошибок и `RETURNING` +`schema`-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама +отображает `pgx.ErrNoRows`/`sql.ErrNoRows` в sentinel. `RETURNING` используется и в +SQLite (>=3.35), поэтому вставки с возвратом `created_at` переносятся один-в-один. + +## Risks / Trade-offs + +- [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9). +- [Пропускная способность записи SQLite под многими пользователями] → WAL, + `busy_timeout`, один писатель; документируем как режим малой инсталляции. +- [Потеря валидации `jsonb`] → хранение как TEXT, проверка JSON при необходимости в Go. +- [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты. +- [Расхождение миграций] → симметричные наборы, прогон `up` на обоих в тестах. +- [Лимит параметров SQLite в `IN (...)`] → малые батчи (`WORKER_BATCH`), построение + списка в Go. + +## Migration Plan + +1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт `postgres`. +2. Добавить SQLite-реализации, миграции и фабрику; включение — `DB_BACKEND=sqlite`. +3. Существующие развёртывания не меняются; откат — вернуть `DB_BACKEND=postgres`. +4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения. + +## Open Questions + +- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих + запросов с точным совпадением/префиксом. diff --git a/openspec/changes/sqlite-metadata-backend/proposal.md b/openspec/changes/sqlite-metadata-backend/proposal.md new file mode 100644 index 0000000..e28ffb6 --- /dev/null +++ b/openspec/changes/sqlite-metadata-backend/proposal.md @@ -0,0 +1,37 @@ +# Proposal + +## Why + +Слой метаданных жёстко привязан к PostgreSQL: каждый store держит `*pgxpool.Pool` и +пишет SQL диалекта Postgres. Инсталляциям без внешней БД нужен встроенный, +самодостаточный режим на SQLite с паритетом поведения для одного экземпляра приложения. + +## What Changes + +- Ввести интерфейсы репозиториев по доменам (auth, albums, members, photos, social, + notifications, sharelinks); существующие pgx-типы становятся одной из реализаций. +- Добавить SQLite-реализации (~72 метода, включая жизненный цикл объектов, сборку + мусора и очередь вариантов) на чистом Go (`modernc.org/sqlite`, `CGO_ENABLED=0`). +- Добавить отдельный набор миграций для SQLite. +- Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH`; валидировать условно; настроить + WAL, `busy_timeout`, `foreign_keys`. +- Добавить фабрику сборки репозиториев по выбранному бэкенду. +- Обеспечить единый контрактный набор тестов, прогоняемый на обоих бэкендах. + +## Capabilities + +### New Capabilities +- `metadata-backends`: выбор и работа слоя метаданных — PostgreSQL и встроенная SQLite с паритетным поведением для одного экземпляра. + +### Modified Capabilities + + +## Impact + +- `backend/internal/*/store.go` и `backend/internal/photos/objects.go` (интерфейсы + + SQLite-реализации), `internal/db`, `internal/app`, `cmd/server`, `config`, `migrations`. +- Тесты: общий контрактный набор для Postgres и SQLite. +- `.env.example`, `project.md`. +- Ограничение: один экземпляр приложения на один SQLite-файл (без горизонтального + масштабирования); Postgres остаётся поведением по умолчанию. +- Не затрагивает слой хранения. diff --git a/openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md b/openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md new file mode 100644 index 0000000..68052b9 --- /dev/null +++ b/openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md @@ -0,0 +1,76 @@ +# Spec Delta + +## Purpose + +Выбор и работа слоя метаданных: внешняя PostgreSQL или встроенная SQLite, с +паритетным поведением для одного экземпляра приложения. + +## ADDED Requirements + +### Requirement: Выбор бэкенда метаданных +Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации, +при этом PostgreSQL SHALL оставаться значением по умолчанию. + +#### Scenario: Выбор PostgreSQL +- **WHEN** в конфигурации задан бэкенд PostgreSQL +- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL + +#### Scenario: Выбор встроенной SQLite +- **WHEN** в конфигурации задан бэкенд SQLite +- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы + +#### Scenario: Неполная конфигурация +- **WHEN** конфигурация выбранного бэкенда неполна +- **THEN** система отказывается стартовать с явной ошибкой конфигурации + +### Requirement: Паритет поведения бэкендов +Оба бэкенда метаданных SHALL обеспечивать одинаковое наблюдаемое поведение системы: +аутентификацию, альбомы, роли и доступ, фото, объектный журнал, сборку мусора и +очередь генерации вариантов. + +#### Scenario: Одинаковое поведение API +- **WHEN** один и тот же сценарий выполняется на PostgreSQL и на SQLite +- **THEN** наблюдаемые результаты и ошибки API совпадают + +#### Scenario: Соблюдение прав доступа +- **WHEN** на любом бэкенде участник без прав выполняет запрещённое действие +- **THEN** система отклоняет действие одинаковым образом + +### Requirement: Встроенный одиночный экземпляр +Бэкенд SQLite SHALL работать как самодостаточный режим для одного экземпляра приложения, +и система SHALL отвергать или явно документировать использование одного файла SQLite +несколькими экземплярами. + +#### Scenario: Один экземпляр +- **WHEN** приложение запущено в единственном экземпляре +- **THEN** SQLite корректно обслуживает всех пользователей этого экземпляра + +#### Scenario: Совместная работа с файлом +- **WHEN** предполагается совместная работа нескольких экземпляров с одним файлом SQLite +- **THEN** конфигурация считает такой режим неподдерживаемым + +### Requirement: Атомарность жизненного цикла объектов +На обоих бэкендах привязка готового объекта к фото и заявка объекта на удаление SHALL +быть атомарными и SHALL исключать привязку объекта, который собирается быть удалён, и +удаление используемого объекта. + +#### Scenario: Конкурентная привязка и сборка мусора +- **WHEN** привязка объекта и заявка на его удаление происходят одновременно +- **THEN** система либо привязывает объект без удаления, либо удаляет его без привязки + +#### Scenario: Повторная привязка +- **WHEN** объект уже привязан к фото в роли +- **THEN** повторная привязка приводит к согласованному состоянию без дублей + +### Requirement: Устойчивость к перезапуску +Очередь генерации вариантов и сборка мусора SHALL восстанавливаться из состояния слоя +метаданных после перезапуска на обоих бэкендах, и система SHALL применять схему +метаданных при старте. + +#### Scenario: Незавершённые задания +- **WHEN** процесс перезапускается с незавершёнными заданиями генерации вариантов +- **THEN** обработка заданий продолжается + +#### Scenario: Применение схемы +- **WHEN** приложение стартует с выбранным бэкендом +- **THEN** схема метаданных выбранного бэкенда приведена к актуальному состоянию diff --git a/openspec/changes/sqlite-metadata-backend/tasks.md b/openspec/changes/sqlite-metadata-backend/tasks.md new file mode 100644 index 0000000..e8bf6c7 --- /dev/null +++ b/openspec/changes/sqlite-metadata-backend/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +## 1. Интерфейсы репозиториев и контрактный набор + +- [x] 1.1 Ввести `Repository` в доменах auth, albums, members, photos, social, notifications, sharelinks и перевести сервисы на интерфейсы; проверка — `go build ./...` проходит, PostgreSQL-реализации не менялись. +- [x] 1.2 Поднять общий контрактный набор тестов на каждый `Repository`; проверка — набор зелёный на PostgreSQL под `TEST_DATABASE_URL` (пропуск без него). +- [x] 1.3 Зафиксировать маппинг sentinel-ошибок домена и `created_at` через `RETURNING` как часть контракта; проверка — тесты ошибок «не найдено» на Postgres проходят. + +## 2. SQLite: драйвер, миграции, фабрика + +- [x] 2.1 Добавить `modernc.org/sqlite` и подключение с `journal_mode=WAL`, `busy_timeout`, `foreign_keys=ON`; проверка — тест открытия, `Ping` и применения прагм проходит. +- [x] 2.2 Добавить набор `migrations/sqlite/` и `db.MigrateSQLite`; проверка — тест применяет схему на временном файле и повторный запуск идемпотентен. +- [x] 2.3 Реализовать фабрику `store.NewPostgres(pool)` / `store.NewSQLite(db)`, возвращающую бандл `Repository`; проверка — тест собирает бандл для обоих бэкендов и `go build ./...` проходит. + +## 3. SQLite-реализации доменов + +- [x] 3.1 Реализовать SQLite-store для auth, members, albums; проверка — контрактные тесты этих доменов зелёные на SQLite. +- [x] 3.2 Реализовать SQLite-store для photos (фото, метки, корзина, backfill EXIF); проверка — контрактные тесты photos зелёные на SQLite. +- [x] 3.3 Реализовать SQLite-логику объектов (`BindObject`, `ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`, `FailVariantJob`) в `BEGIN IMMEDIATE`; проверка — тесты на атомарность привязки/GC и на backoff зелёные. +- [x] 3.4 Реализовать SQLite-store для social, notifications, sharelinks; проверка — контрактные тесты этих доменов зелёные на SQLite. + +## 4. Конфигурация, проводка, health, документация + +- [x] 4.1 Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH` с условной валидацией; проверка — юнит-тесты `config.Load` для обоих бэкендов и неполной конфигурации. +- [x] 4.2 Перевести `app.New` на бандл репозиториев и выбрать бэкенд в `cmd/server`; проверка — сервер стартует с обоими значениями `DB_BACKEND`, db-метка в health динамическая. +- [x] 4.3 Обновить `.env.example`, `project.md` и README с ограничением «один экземпляр на файл SQLite»; проверка — документированный запуск с `DB_BACKEND=sqlite` стартует. + +## 5. Интеграционные проверки + +- [x] 5.1 Прогнать полный контрактный набор на обоих бэкендах и сравнить результаты; проверка — одинаковые исходы на Postgres и SQLite. +- [x] 5.2 End-to-end на SQLite (регистрация → альбом → загрузка → превью → удаление → GC) с перезапуском процесса; проверка — очередь вариантов и GC продолжаются, сирот нет. +- [x] 5.3 Убедиться, что дефолт `postgres` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше. diff --git a/openspec/changes/storage-orphan-reconciliation/.openspec.yaml b/openspec/changes/storage-orphan-reconciliation/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/storage-orphan-reconciliation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/storage-orphan-reconciliation/design.md b/openspec/changes/storage-orphan-reconciliation/design.md new file mode 100644 index 0000000..30f0fd8 --- /dev/null +++ b/openspec/changes/storage-orphan-reconciliation/design.md @@ -0,0 +1,82 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее состояние, определяющее подход: + +- `storage.Backend` умеет Put/Delete/Get/URL/ServeObject/Ping/EnsureBucket/Bucket, но не + умеет перечислять объекты. +- Сборка мусора (`photos.Worker`) управляется журналом `objects`: `ClaimGarbage` выбирает + строки без привязок и удаляет блоб. Блобы без строки в журнале воркеру невидимы. +- Ключи объектов — `objects//`, слой метаданных хранит их в + `objects.storage_key`. +- Есть два бэкенда хранения (`s3`, `fs`) и два бэкенда метаданных (`postgres`, `sqlite`) + за интерфейсами `storage.Backend` и доменными `Repository`. + +## Goals / Non-Goals + +**Goals:** +- Ручная сверка хранилища и журнала: инвентаризация обеих сторон, отчёт, безопасное удаление. +- Работа с любой комбинацией бэкендов хранения и метаданных. +- Безопасное поведение по умолчанию: ничего не удаляется без явного флага, свежие блобы + защищены. + +**Non-Goals:** +- Автоматическая периодическая реконсиляция в воркере. +- Восстановление пропавших блобов (в журнале есть, в хранилище нет). +- Миграция блобов между бэкендами. +- Сверка префиксов, отличных от объектов. + +## Decisions + +### D1. Перечисление в `storage.Backend` +Добавляем `List(ctx context.Context, prefix string) ([]ObjectInfo, error)` где +`ObjectInfo{Key string; Size int64; ModTime time.Time}`. +- S3: `ListObjectsV2` с пагинацией по `ContinuationToken`, `Prefix`. +- ФС: обход корня (`filepath.WalkDir`), ключ — путь относительно корня, размер/время из + `os.Stat`. +- Альтернатива: не трогать интерфейс и добавить отдельный тип для реконсиляции — отвергнуто, + перечисление логично принадлежит бэкенду хранения. + +### D2. Ключи журнала в метаданных +Добавляем `AllStorageKeys(ctx) ([]string, error)` в `photos.Repository`; реализации: +`SELECT storage_key FROM objects` (pg и sqlite). Контрактный набор покрывает метод. + +### D3. Команда `cmd/reconcile` +Отдельная maintenance-команда, читает конфиг, открывает выбранные бэкенды +(`storage.Open` + бандл репозиториев), строит множества и печатает отчёт. +Флаги: `--delete` (bool), `--min-age` (duration, по умолчанию `1h`), `--sample` (число +примеров в отчёте). Область — фиксированный префикс `objects/`. + +### D4. Классификация расхождений +- **Сироты** = ключи хранилища под `objects/`, которых нет в журнале; кандидаты на удаление. +- **Пропавшие** = ключи журнала, которых нет в хранилище; только отчёт. +- Сироты моложе `--min-age` не удаляются (защита от гонки с идущей загрузкой: блоб уже + записан, строка ещё не создана). + +### D5. Безопасность +По умолчанию — только отчёт и нулевое воздействие. Удаление только по `--delete`, только +под `objects/`, только старше `--min-age`. Пропавшие никогда не «исправляются». + +## Risks / Trade-offs + +- [Гонка с загрузкой] → `--min-age` пропускает молодые блобы; рекомендуем запускать в + спокойный период. +- [Большие бакеты: память/время] → пагинация S3 и потоковый обход ФС; в отчёте лишь + примеры, не полный список. +- [Несогласованность формата ключей между бэкендами] → оба бэкенда отдают ключи + относительно корня/перфикса без ведущего слэша; контракт фиксируется тестами. +- [Удаление используемого блоба] → сверка выполняется вручную и в спокойный период; журнал + — источник истины, поэтому «сирота» по определению не имеет строки. + +## Migration Plan + +1. Добавить `List` в интерфейс — S3 и ФС реализуют его (компилятор требует оба). +2. Добавить `AllStorageKeys` в `photos.Repository` — обе реализации. +3. Добавить `cmd/reconcile` (аддитивно, ничего не меняет в работающем сервисе). +4. Данные не мигрируются; команда запускается вручную по необходимости. + +## Open Questions + +- Нужно ли со временем добавить восстановление пропавших блобов или отметку повреждённых + записей журнала. diff --git a/openspec/changes/storage-orphan-reconciliation/proposal.md b/openspec/changes/storage-orphan-reconciliation/proposal.md new file mode 100644 index 0000000..949e015 --- /dev/null +++ b/openspec/changes/storage-orphan-reconciliation/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +Сборка мусора воркера управляется журналом `objects` и никогда не сканирует хранилище. +Поэтому блобы, у которых нет строки в журнале (после сброса БД в обход журнала, +исторических миграций или ошибок), накапливаются и не удаляются никогда: воркер их не +видит. Нужна ручная сверка «хранилище ∖ журнал». + +## What Changes + +- Добавить в `storage.Backend` перечисление объектов под префиксом (ключ, размер, время + изменения); реализовать для S3 (`ListObjectsV2`) и локальной ФС (обход каталога). +- Добавить в слой метаданных чтение всех ключей журнала `objects` (Postgres и SQLite). +- Добавить maintenance-команду `cmd/reconcile`: по умолчанию — только отчёт (сироты и + пропавшие), флаг `--delete` удаляет сироты с учётом минимального возраста + (`--min-age`), область ограничена префиксом `objects/`. +- Пропавшие ключи (есть в журнале, нет в хранилище) только сообщаются — автоматически не + «исправляются». + +## Capabilities + +### New Capabilities +- `storage-reconciliation`: ручная сверка объектного хранилища с журналом метаданных и удаление осиротевших блобов. + +### Modified Capabilities + + +## Impact + +- `backend/internal/storage` (интерфейс + перечисление для S3 и ФС). +- `backend/internal/photos` (`Repository.AllStorageKeys`, реализации pg и sqlite). +- Новый `backend/cmd/reconcile`. +- `README.md`, `project.md`. +- Не меняет API и существующие спеки; дополняет `media-objects` ручной сверкой к + журнальной сборке мусора. diff --git a/openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md b/openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md new file mode 100644 index 0000000..445029a --- /dev/null +++ b/openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md @@ -0,0 +1,65 @@ +# Spec Delta + +## Purpose + +Ручная сверка объектного хранилища с журналом метаданных: инвентаризация обеих сторон, +отчёт о расхождениях и безопасное удаление осиротевших блобов. + +## ADDED Requirements + +### Requirement: Инвентаризация хранилища +Слой хранения SHALL перечислять объекты под заданным префиксом, возвращая ключ, размер и +время изменения, независимо от бэкенда (S3-совместимое или локальная ФС). + +#### Scenario: Перечисление в S3-совместимом хранилище +- **WHEN** запрашивается список объектов под префиксом +- **THEN** возвращаются все ключи с этим префиксом с их размером и временем изменения + +#### Scenario: Перечисление в локальной ФС +- **WHEN** хранилищем выбрана локальная ФС +- **THEN** возвращаются ключи всех блобов под префиксом с их размером и временем изменения + +### Requirement: Инвентаризация журнала +Слой метаданных SHALL возвращать все ключи, зарегистрированные в журнале `objects`, на +обоих бэкендах (PostgreSQL и SQLite). + +#### Scenario: Ключи журнала +- **WHEN** запрашиваются ключи журнала +- **THEN** возвращается множество ключей всех строк `objects` + +### Requirement: Отчёт о расхождениях +По умолчанию сверка SHALL выполняться в режиме отчёта и SHALL не изменять хранилище; +отчёт SHALL различать сироты (в хранилище есть, в журнале нет) и пропавшие (в журнале +есть, в хранилище нет). + +#### Scenario: Только отчёт +- **WHEN** сверка запущена без флага удаления +- **THEN** система выводит число и примеры сирот и пропавших, ничего не удаляя + +#### Scenario: Пропавшие не восстанавливаются +- **WHEN** ключ есть в журнале, но отсутствует в хранилище +- **THEN** он сообщается как пропавший и не создаётся и не удаляется + +### Requirement: Удаление сирот по явному запросу +Удаление сирот SHALL выполняться только по явному флагу, только для ключей под префиксом +объектов и только для блобов старше заданного минимального возраста. + +#### Scenario: Удаление включено +- **WHEN** сверка запущена с флагом удаления +- **THEN** сироты старше минимального возраста удаляются из хранилища, а более молодые пропускаются + +#### Scenario: Защита от гонки с загрузкой +- **WHEN** блоб создан недавно (моложе минимального возраста) +- **THEN** он не удаляется, даже если строки в журнале ещё нет + +#### Scenario: Ограничение области +- **WHEN** удаление включено +- **THEN** удаляются только ключи под префиксом объектов, остальные не затрагиваются + +### Requirement: Независимость от бэкендов +Сверка SHALL работать с любой комбинацией бэкенда хранения (S3 или ФС) и бэкенда +метаданных (PostgreSQL или SQLite). + +#### Scenario: Комбинации бэкендов +- **WHEN** сверка запускается на выбранной конфигурации +- **THEN** она использует выбранные бэкенды хранения и метаданных без изменений кода diff --git a/openspec/changes/storage-orphan-reconciliation/tasks.md b/openspec/changes/storage-orphan-reconciliation/tasks.md new file mode 100644 index 0000000..51e74fd --- /dev/null +++ b/openspec/changes/storage-orphan-reconciliation/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +## 1. Перечисление в хранилище + +- [x] 1.1 Добавить `List(ctx, prefix)` (`[]ObjectInfo{Key,Size,ModTime}`) в `storage.Backend` и реализовать в `storage.S3` через `ListObjectsV2` с пагинацией; проверка — тест перечисления под префиксом (под `S3_ENDPOINT`, иначе пропуск). +- [x] 1.2 Реализовать `List` в `storage.FS` обходом корня с относительными ключами; проверка — юнит-тест на временном каталоге. + +## 2. Ключи журнала в метаданных + +- [x] 2.1 Добавить `AllStorageKeys` в `photos.Repository` и реализовать в pg- и sqlite-store; проверка — контрактный тест на обоих бэкендах. + +## 3. Команда reconcile + +- [x] 3.1 Реализовать `cmd/reconcile`: инвентаризация обеих сторон, классификация сирот/пропавших, отчёт по умолчанию; проверка — юнит-тесты классификации. +- [x] 3.2 Добавить флаги `--delete`, `--min-age`, `--sample` с ограничением области `objects/` и защитой свежих блобов; проверка — тест: молодой блоб не удаляется, старый сирота удаляется, пропавшие не трогаются. +- [x] 3.3 Собрать бэкенды через конфиг (`storage.Open` + бандл репозиториев) для postgres/sqlite и s3/fs; проверка — запуск на fs и s3. + +## 4. Документация и интеграция + +- [x] 4.1 Обновить `README.md` и `project.md` с описанием команды и её безопасного использования; проверка — документированный запуск работает. +- [x] 4.2 Интеграционная проверка: создать сироту, прогнать отчёт, затем `--delete`, убедиться в удалении и в том, что журнальные записи не затронуты. diff --git a/project.md b/project.md index 56f2baa..3f31e54 100644 --- a/project.md +++ b/project.md @@ -144,6 +144,10 @@ PG_PASSWORD=change_me PG_DATABASE=shared_album PG_SSLMODE=disable +# Metadata backend +DB_BACKEND=sqlite # sqlite (default, embedded single instance) | postgres +SQLITE_PATH=./galo.db # path when DB_BACKEND=sqlite + # S3 (Garage) - внутреннее хранилище, клиенту не раскрывается S3_ENDPOINT=http://s3.localnet:3900 S3_REGION=garage @@ -153,8 +157,8 @@ S3_BUCKET=albums S3_USE_PATH_STYLE=true # Storage backend -STORAGE_BACKEND=s3 # s3 (default) | fs (local filesystem) -FS_ROOT=/var/lib/galo/objects # required when STORAGE_BACKEND=fs +STORAGE_BACKEND=fs # fs (default, local filesystem) | s3 +FS_ROOT=./data # root directory for object blobs (fs) URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET OBJECT_URL_TTL=1h # lifetime of signed object URLs @@ -384,6 +388,12 @@ colors: { не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие блобы — перенос выполняется вручную. +**Реконсиляция хранилища.** Сборка мусора управляется журналом `objects` и не сканирует +хранилище, поэтому осиротевшие блобы (нет строки в журнале) не удаляются автоматически. +Ручная команда `cmd/reconcile` сверяет хранилище с журналом: по умолчанию только отчёт, +`--delete` удаляет сироты старше `--min-age` (по умолчанию 1h) в пределах префикса +`objects/`; пропавшие ключи только сообщаются. + ## 11. API-эндпоинты ### Auth