From ec8d9075659b5468ad37f7a6e417410c5bef5fc2 Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 09:15:03 +0300 Subject: [PATCH 1/5] feat(storage): serve media through backend and add local FS backend - introduce storage.Backend with S3 and local file-system implementations - deliver all blobs via signed GET/HEAD /api/objects/ (Range, Content-Type) - stream S3 objects through the backend; remove presigned URL path - share shared HMAC Signer between backends - remove S3_PUBLIC_URL; rename S3_PRESIGN_TTL to OBJECT_URL_TTL - make storage label in health dynamic; nginx proxy_buffering off for objects --- .env.example | 14 +- README.md | 4 +- backend/cmd/backfill-exif/main.go | 2 +- backend/cmd/server/main.go | 2 +- backend/internal/albums/service.go | 8 +- backend/internal/app/app.go | 35 ++- backend/internal/app/health_test.go | 71 ++++++ backend/internal/app/objects_test.go | 110 +++++++++ backend/internal/app/share_flow_test.go | 211 ++++++++++++++++++ backend/internal/config/config.go | 32 ++- backend/internal/config/config_test.go | 70 ++++++ backend/internal/photos/helpers_test.go | 28 ++- backend/internal/photos/service.go | 16 +- backend/internal/photos/worker_test.go | 15 +- backend/internal/storage/fs.go | 201 +++++++++++++++++ backend/internal/storage/fs_test.go | 174 +++++++++++++++ backend/internal/storage/s3_test.go | 142 ++++++++++++ backend/internal/storage/signer.go | 80 +++++++ backend/internal/storage/storage.go | 184 +++++++++++++-- deploy/nginx-sse.conf | 8 + .../local-fs-storage-backend/.openspec.yaml | 2 + .../local-fs-storage-backend/design.md | 107 +++++++++ .../local-fs-storage-backend/proposal.md | 40 ++++ .../specs/photos/spec.md | 31 +++ .../specs/share-links/spec.md | 14 ++ .../specs/storage-backends/spec.md | 108 +++++++++ .../changes/local-fs-storage-backend/tasks.md | 39 ++++ project.md | 31 ++- 28 files changed, 1714 insertions(+), 65 deletions(-) create mode 100644 backend/internal/app/health_test.go create mode 100644 backend/internal/app/objects_test.go create mode 100644 backend/internal/app/share_flow_test.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/storage/fs.go create mode 100644 backend/internal/storage/fs_test.go create mode 100644 backend/internal/storage/s3_test.go create mode 100644 backend/internal/storage/signer.go create mode 100644 openspec/changes/local-fs-storage-backend/.openspec.yaml create mode 100644 openspec/changes/local-fs-storage-backend/design.md create mode 100644 openspec/changes/local-fs-storage-backend/proposal.md create mode 100644 openspec/changes/local-fs-storage-backend/specs/photos/spec.md create mode 100644 openspec/changes/local-fs-storage-backend/specs/share-links/spec.md create mode 100644 openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md create mode 100644 openspec/changes/local-fs-storage-backend/tasks.md diff --git a/.env.example b/.env.example index 5392ab3..3ed5814 100644 --- a/.env.example +++ b/.env.example @@ -6,15 +6,23 @@ PG_PASSWORD=change_me PG_DATABASE=shared_album PG_SSLMODE=disable -# S3 (Garage on s3.localnet) +# S3 (Garage on s3.localnet) - internal storage, never exposed to clients S3_ENDPOINT=http://s3.localnet:3900 S3_REGION=garage S3_ACCESS_KEY=GKchange_me S3_SECRET_KEY=change_me S3_BUCKET=galo-data S3_USE_PATH_STYLE=true -S3_PUBLIC_URL=http://s3.localnet:3900/galo-data -S3_PRESIGN_TTL=1h + +# Storage backend: s3 (default, S3-compatible/Garage) or fs (local filesystem). +# 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 +# Signs object URLs; falls back to JWT_SECRET when empty +URL_SECRET= +# Lifetime of signed object URLs +OBJECT_URL_TTL=1h # Auth JWT_SECRET=change_me_long_random diff --git a/README.md b/README.md index 48e92d7..94bae5e 100644 --- a/README.md +++ b/README.md @@ -129,11 +129,11 @@ backend/ cmd/migrate/ # применение миграций internal/config # чтение env internal/db # pgx + golang-migrate - internal/storage # S3 (Garage) + internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд internal/auth # JWT, bcrypt, refresh internal/albums # альбомы internal/members # участники, роли, инвайты - internal/photos # загрузка, варианты, presigned URL + internal/photos # загрузка, варианты, отдача через бэкенд internal/social # лайки, комментарии internal/notifications # персональные уведомления internal/sharelinks # ссылки для просмотра diff --git a/backend/cmd/backfill-exif/main.go b/backend/cmd/backfill-exif/main.go index a0d9309..aa525f4 100644 --- a/backend/cmd/backfill-exif/main.go +++ b/backend/cmd/backfill-exif/main.go @@ -35,7 +35,7 @@ func main() { } defer pool.Close() - store, err := storage.New(ctx, cfg.S3) + store, err := storage.Open(ctx, cfg) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index bda98af..e97ba86 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -55,7 +55,7 @@ func main() { } defer pool.Close() - store, err := storage.New(ctx, cfg.S3) + store, err := storage.Open(ctx, cfg) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) diff --git a/backend/internal/albums/service.go b/backend/internal/albums/service.go index 79022a1..5bd1fd3 100644 --- a/backend/internal/albums/service.go +++ b/backend/internal/albums/service.go @@ -21,10 +21,10 @@ type Service struct { store *Store members MemberLookup hub *sse.Hub - storage *storage.Client + storage storage.Backend } -func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage *storage.Client) *Service { +func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service { return &Service{store: store, members: members, hub: hub, storage: storage} } @@ -61,12 +61,12 @@ func (s *Service) List(ctx context.Context, userID string) ([]models.Album, erro return out, nil } -// attachCover fills CoverThumbURL from the presigned thumbnail key, when present. +// attachCover fills CoverThumbURL from the signed thumbnail URL, when present. func (s *Service) attachCover(ctx context.Context, a *models.Album) { if s.storage == nil || a == nil || a.CoverThumbKey == "" { return } - if url, err := s.storage.PresignGet(ctx, a.CoverThumbKey); err == nil { + if url, err := s.storage.URL(ctx, a.CoverThumbKey); err == nil { a.CoverThumbURL = url } } diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0c323d5..325805a 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "io/fs" "net/http" "strings" @@ -27,7 +28,7 @@ import ( type Server struct { cfg *config.Config pool *pgxpool.Pool - storage *storage.Client + storage storage.Backend hub *sse.Hub albumsStore *albums.Store @@ -47,7 +48,7 @@ type Server struct { share *sharelinks.Service } -func New(cfg *config.Config, pool *pgxpool.Pool, st *storage.Client, hub *sse.Hub) *Server { +func New(cfg *config.Config, pool *pgxpool.Pool, st storage.Backend, hub *sse.Hub) *Server { authStore := auth.NewStore(pool) tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL) authSvc := auth.NewService(authStore, tm) @@ -109,6 +110,8 @@ func (s *Server) Router() *gin.Engine { api := r.Group("/api") api.GET("/health", s.health) + api.GET("/objects/*key", s.serveObject) + api.HEAD("/objects/*key", s.serveObject) api.POST("/auth/register", authHandler.Register) api.POST("/auth/login", authHandler.Login) @@ -183,17 +186,37 @@ func (s *Server) health(c *gin.Context) { if err := s.pool.Ping(ctx); err != nil { dbStatus = "error" } - s3Status := "ok" + storageLabel := s.cfg.Storage.Backend + if storageLabel == "" { + storageLabel = "s3" + } + storeStatus := "ok" if err := s.storage.Ping(ctx); err != nil { - s3Status = "error" + storeStatus = "error" } status := http.StatusOK overall := "ok" - if dbStatus != "ok" || s3Status != "ok" { + if dbStatus != "ok" || storeStatus != "ok" { status = http.StatusServiceUnavailable overall = "degraded" } - c.JSON(status, gin.H{"status": overall, "db": dbStatus, "s3": s3Status}) + c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus}) +} + +func (s *Server) serveObject(c *gin.Context) { + key := strings.TrimPrefix(c.Param("key"), "/") + switch err := s.storage.ServeObject(c.Writer, c.Request, key); { + case err == nil: + return + case errors.Is(err, storage.ErrSignedURLInvalid): + c.Status(http.StatusForbidden) + case errors.Is(err, storage.ErrObjectNotFound): + httpx.RespondError(c, httpx.NotFound("object_not_found", "object not found")) + case errors.Is(err, storage.ErrRangeNotSatisfiable): + c.Status(http.StatusRequestedRangeNotSatisfiable) + default: + c.Status(http.StatusInternalServerError) + } } func (s *Server) albumSSE(c *gin.Context) { diff --git a/backend/internal/app/health_test.go b/backend/internal/app/health_test.go new file mode 100644 index 0000000..f79c4b1 --- /dev/null +++ b/backend/internal/app/health_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "shared-album/internal/config" + "shared-album/internal/storage" +) + +func mustSigner(t *testing.T) *storage.Signer { + t.Helper() + signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) + if err != nil { + t.Fatalf("signer: %v", err) + } + return signer +} + +func 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} + + call := func() map[string]any { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/api/health", nil) + srv.health(c) + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + return body + } + + srv.cfg.Storage.Backend = "fs" + if body := call(); body["fs"] != "ok" { + t.Fatalf("expected fs label ok, got %v", body) + } + + srv.cfg.Storage.Backend = "s3" + if body := call(); body["s3"] != "ok" { + t.Fatalf("expected s3 label ok, got %v", body) + } + + srv.cfg.Storage.Backend = "" + if body := call(); body["s3"] != "ok" { + t.Fatalf("expected default s3 label, got %v", body) + } +} diff --git a/backend/internal/app/objects_test.go b/backend/internal/app/objects_test.go new file mode 100644 index 0000000..db63f67 --- /dev/null +++ b/backend/internal/app/objects_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "shared-album/internal/config" + "shared-album/internal/sse" + "shared-album/internal/storage" +) + +func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) + if err != nil { + t.Fatalf("signer: %v", err) + } + fs, err := storage.NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("new fs: %v", err) + } + srv := New(&config.Config{}, nil, fs, sse.NewHub(1)) + return fs, srv.Router() +} + +func TestServeObjectRoute(t *testing.T) { + ctx := context.Background() + fs, r := newTestServerWithFS(t) + key := "objects/abc/original.jpg" + if err := fs.Put(ctx, key, strings.NewReader("0123456789"), 10, "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + raw, err := fs.URL(ctx, key) + if err != nil { + t.Fatalf("url: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + + do := func(target string, rangeHeader string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + if rangeHeader != "" { + req.Header.Set("Range", rangeHeader) + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec + } + + t.Run("valid", func(t *testing.T) { + rec := do(u.Path+"?"+u.RawQuery, "") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.String() != "0123456789" { + t.Fatalf("body = %q", rec.Body.String()) + } + }) + + t.Run("range", func(t *testing.T) { + rec := do(u.Path+"?"+u.RawQuery, "bytes=2-5") + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if rec.Body.String() != "2345" { + t.Fatalf("body = %q, want 2345", rec.Body.String()) + } + }) + + t.Run("expired", func(t *testing.T) { + q := u.Query() + q.Set("e", strconv.FormatInt(time.Now().Add(-time.Minute).Unix(), 10)) + rec := do(u.Path+"?"+q.Encode(), "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + }) + + t.Run("forged", func(t *testing.T) { + q := u.Query() + q.Set("s", "deadbeef") + rec := do(u.Path+"?"+q.Encode(), "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + }) + + t.Run("missing", func(t *testing.T) { + raw, err := fs.URL(ctx, "objects/def/original.jpg") + if err != nil { + t.Fatalf("url: %v", err) + } + mu, _ := url.Parse(raw) + rec := do(mu.Path+"?"+mu.RawQuery, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + }) +} diff --git a/backend/internal/app/share_flow_test.go b/backend/internal/app/share_flow_test.go new file mode 100644 index 0000000..1d5b0b3 --- /dev/null +++ b/backend/internal/app/share_flow_test.go @@ -0,0 +1,211 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "shared-album/internal/albums" + "shared-album/internal/auth" + "shared-album/internal/config" + "shared-album/internal/members" + "shared-album/internal/models" + "shared-album/internal/photos" + "shared-album/internal/sharelinks" + "shared-album/internal/sse" + "shared-album/internal/storage" +) + +func integrationPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Skipf("cannot connect: %v", err) + } + if err := pool.Ping(context.Background()); err != nil { + t.Skipf("cannot ping: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +func makeTestPNG(t *testing.T) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for x := 0; x < 4; x++ { + for y := 0; y < 4; y++ { + img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +// runShareFlow uploads a photo and validates the read-only share flow returns a +// usable photo URL. verify inspects/serves the URL according to the backend. +func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.T, router *gin.Engine, raw string)) { + t.Helper() + ctx := context.Background() + pool := integrationPool(t) + gin.SetMode(gin.TestMode) + + hub := sse.NewHub(1) + srv := New(&config.Config{}, pool, backend, hub) + router := srv.Router() + + authStore := auth.NewStore(pool) + owner := &models.User{ID: auth.NewID(), Email: auth.NewID()[:8] + "@test.local", PasswordHash: "x", Name: "Owner"} + if err := authStore.CreateUser(ctx, owner); err != nil { + t.Fatalf("create user: %v", err) + } + + albumsStore := albums.NewStore(pool) + albumsSvc := albums.NewService(albumsStore, members.NewStore(pool), hub, backend) + album, err := albumsSvc.Create(ctx, owner.ID, albums.CreateInput{Title: "Share Album"}) + if err != nil { + t.Fatalf("create album: %v", err) + } + + photosSvc := photos.NewService(photos.NewStore(pool), albumsSvc, backend, hub) + pngData := makeTestPNG(t) + if _, err := photosSvc.Upload(ctx, photos.UploadInput{ + AlbumID: album.ID, UserID: owner.ID, Filename: "pic.png", Data: pngData, + }); err != nil { + t.Fatalf("upload: %v", err) + } + + var keys []string + rows, err := pool.Query(ctx, + `SELECT o.storage_key FROM photo_objects po + JOIN objects o ON o.id = po.object_id + JOIN photos p ON p.id = po.photo_id + WHERE p.album_id = $1`, album.ID) + if err == nil { + for rows.Next() { + var k string + if rows.Scan(&k) == nil { + keys = append(keys, k) + } + } + rows.Close() + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM photos WHERE album_id = $1`, album.ID) + for _, k := range keys { + _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE storage_key = $1`, k) + } + _ = backend.Delete(context.Background(), keys...) + _, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, album.ID) + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, owner.ID) + }) + + shareSvc := sharelinks.NewService(sharelinks.NewStore(pool), albumsSvc) + link, err := shareSvc.Create(ctx, album.ID, owner.ID, time.Hour) + if err != nil { + t.Fatalf("share create: %v", err) + } + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/share/"+link.Token+"/photos", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("share photos status = %d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Photos []models.Photo `json:"photos"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if len(body.Photos) != 1 { + t.Fatalf("photos len = %d, want 1", len(body.Photos)) + } + if body.Photos[0].OriginalURL == "" { + t.Fatalf("empty original url") + } + verify(t, router, body.Photos[0].OriginalURL) +} + +func TestShareFlowWithFS(t *testing.T) { + signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) + if err != nil { + t.Fatalf("signer: %v", err) + } + fs, err := storage.NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("new fs: %v", err) + } + runShareFlow(t, fs, func(t *testing.T, router *gin.Engine, raw string) { + if !strings.Contains(raw, "/api/objects/") { + t.Fatalf("expected fs signed url, got %q", raw) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url: %v", err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("fs object fetch status = %d", rec.Code) + } + }) +} + +func TestShareFlowWithS3(t *testing.T) { + endpoint := os.Getenv("S3_ENDPOINT") + if endpoint == "" { + t.Skip("S3_ENDPOINT not set") + } + signer, err := storage.NewSigner("http://app.test", "secret", time.Hour) + if err != nil { + t.Skipf("signer init: %v", err) + } + cl, err := storage.NewS3(context.Background(), config.S3{ + Endpoint: endpoint, + Region: os.Getenv("S3_REGION"), + AccessKey: os.Getenv("S3_ACCESS_KEY"), + SecretKey: os.Getenv("S3_SECRET_KEY"), + Bucket: os.Getenv("S3_BUCKET"), + UsePathStyle: true, + }, signer) + if err != nil { + t.Skipf("storage init: %v", err) + } + runShareFlow(t, cl, func(t *testing.T, router *gin.Engine, raw string) { + if !strings.Contains(raw, "/api/objects/") { + t.Fatalf("expected backend url, got %q", raw) + } + if strings.Contains(raw, "X-Amz-Signature") { + t.Fatalf("presigned url leaked to client: %q", raw) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url: %v", err) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, u.Path+"?"+u.RawQuery, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("s3 object fetch status = %d", rec.Code) + } + }) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f99c843..cc9c135 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,8 +28,6 @@ type S3 struct { SecretKey string Bucket string UsePathStyle bool - PublicURL string - PresignTTL time.Duration } type Auth struct { @@ -54,9 +52,17 @@ type Worker struct { PendingTTL time.Duration } +type Storage struct { + Backend string + FSRoot string + URLSecret string + URLTTL time.Duration +} + type Config struct { Postgres Postgres S3 S3 + Storage Storage Auth Auth Server Server SSE SSE @@ -79,9 +85,9 @@ func Load() (*Config, error) { return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err) } - presignTTL, err := time.ParseDuration(getEnv("S3_PRESIGN_TTL", "1h")) + urlTTL, err := time.ParseDuration(getEnv("OBJECT_URL_TTL", "1h")) if err != nil { - return nil, fmt.Errorf("invalid S3_PRESIGN_TTL: %w", err) + return nil, fmt.Errorf("invalid OBJECT_URL_TTL: %w", err) } heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s")) @@ -130,8 +136,12 @@ func Load() (*Config, error) { SecretKey: getEnv("S3_SECRET_KEY", ""), Bucket: getEnv("S3_BUCKET", "albums"), UsePathStyle: pathStyle, - PublicURL: getEnv("S3_PUBLIC_URL", ""), - PresignTTL: presignTTL, + }, + Storage: Storage{ + Backend: getEnv("STORAGE_BACKEND", "s3"), + FSRoot: getEnv("FS_ROOT", ""), + URLSecret: getEnv("URL_SECRET", ""), + URLTTL: urlTTL, }, Auth: Auth{ JWTSecret: getEnv("JWT_SECRET", ""), @@ -160,6 +170,16 @@ func Load() (*Config, error) { return nil, fmt.Errorf("PG_PASSWORD is required") } + switch cfg.Storage.Backend { + case "s3": + case "fs": + if cfg.Storage.FSRoot == "" { + return nil, fmt.Errorf("FS_ROOT is required when STORAGE_BACKEND=fs") + } + default: + return nil, fmt.Errorf("invalid STORAGE_BACKEND %q (want s3 or fs)", cfg.Storage.Backend) + } + return cfg, nil } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..684e49d --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,70 @@ +package config + +import ( + "testing" + "time" +) + +func setRequiredEnv(t *testing.T) { + t.Helper() + t.Setenv("JWT_SECRET", "test-secret") + t.Setenv("PG_PASSWORD", "test-pass") +} + +func TestLoadStorageBackend(t *testing.T) { + t.Run("defaults to s3", 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) + } + }) + + t.Run("fs with root", func(t *testing.T) { + setRequiredEnv(t) + t.Setenv("STORAGE_BACKEND", "fs") + t.Setenv("FS_ROOT", "/var/lib/galo/objects") + cfg, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "/var/lib/galo/objects" { + t.Fatalf("storage = %+v", cfg.Storage) + } + }) + + t.Run("fs without root", 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") + } + }) + + t.Run("invalid backend", func(t *testing.T) { + setRequiredEnv(t) + t.Setenv("STORAGE_BACKEND", "gcs") + if _, err := Load(); err == nil { + t.Fatal("expected error for invalid backend") + } + }) + + t.Run("object url ttl", func(t *testing.T) { + setRequiredEnv(t) + t.Setenv("STORAGE_BACKEND", "") + t.Setenv("OBJECT_URL_TTL", "30m") + cfg, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Storage.URLTTL != 30*time.Minute { + t.Fatalf("URLTTL = %v, want 30m", cfg.Storage.URLTTL) + } + }) +} diff --git a/backend/internal/photos/helpers_test.go b/backend/internal/photos/helpers_test.go index 56491a3..0659663 100644 --- a/backend/internal/photos/helpers_test.go +++ b/backend/internal/photos/helpers_test.go @@ -100,29 +100,47 @@ func makePNG(t *testing.T) []byte { // newTestService builds a service backed by the real dev storage. It skips when // S3 env is absent so unit runs stay hermetic. -func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, *storage.Client) { +func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) { t.Helper() endpoint := os.Getenv("S3_ENDPOINT") if endpoint == "" { t.Skip("S3_ENDPOINT not set") } - cl, err := storage.New(context.Background(), config.S3{ + signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour) + if err != nil { + t.Skipf("signer init: %v", err) + } + cl, err := storage.NewS3(context.Background(), config.S3{ Endpoint: endpoint, Region: testEnvOr("S3_REGION", "garage"), AccessKey: os.Getenv("S3_ACCESS_KEY"), SecretKey: os.Getenv("S3_SECRET_KEY"), Bucket: testEnvOr("S3_BUCKET", "galo-data"), UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false", - PresignTTL: time.Hour, - }) + }, signer) if err != nil { t.Skipf("storage init: %v", err) } return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl } +// newTestServiceFS builds a service backed by a temporary local file-system +// storage backend so the full flow can run without external infrastructure. +func newTestServiceFS(t *testing.T, pool *pgxpool.Pool) (*Service, storage.Backend) { + t.Helper() + signer, err := storage.NewSigner("http://app.test", "test-secret", time.Hour) + if err != nil { + t.Fatalf("storage init: %v", err) + } + fs, err := storage.NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("storage init: %v", err) + } + return NewService(NewStore(pool), stubAlbums{}, fs, sse.NewHub(1)), fs +} + // cleanupPhoto removes a photo, its objects (rows and blobs) and bindings. -func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl *storage.Client, photoID string) { +func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl storage.Backend, photoID string) { t.Helper() rows, err := pool.Query(ctx, `SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index f328f3f..345b72b 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -24,11 +24,11 @@ type AlbumAccess interface { type Service struct { store *Store albums AlbumAccess - storage *storage.Client + storage storage.Backend hub *sse.Hub } -func NewService(store *Store, albums AlbumAccess, storage *storage.Client, hub *sse.Hub) *Service { +func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service { return &Service{store: store, albums: albums, storage: storage, hub: hub} } @@ -118,7 +118,7 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error { if p.StorageKey != "" { - url, err := s.storage.PresignGet(ctx, p.StorageKey) + url, err := s.storage.URL(ctx, p.StorageKey) if err != nil { return err } @@ -126,12 +126,12 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error { p.DownloadURL = url } if p.ThumbKey != "" { - if url, err := s.storage.PresignGet(ctx, p.ThumbKey); err == nil { + if url, err := s.storage.URL(ctx, p.ThumbKey); err == nil { p.ThumbURL = url } } if p.PreviewKey != "" { - if url, err := s.storage.PresignGet(ctx, p.PreviewKey); err == nil { + if url, err := s.storage.URL(ctx, p.PreviewKey); err == nil { p.PreviewURL = url } } @@ -202,13 +202,13 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo, return p, nil } -// DownloadPublic returns a presigned original URL for a share link. +// DownloadPublic returns a signed original URL for a share link. func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) { p, err := s.GetPublic(ctx, photoID) if err != nil { return "", err } - return s.storage.PresignGet(ctx, p.StorageKey) + return s.storage.URL(ctx, p.StorageKey) } func (s *Service) Get(ctx context.Context, photoID, userID string) (*models.Photo, error) { @@ -236,7 +236,7 @@ func (s *Service) Download(ctx context.Context, photoID, userID string) (string, if err != nil { return "", err } - url, err := s.storage.PresignGet(ctx, p.StorageKey) + url, err := s.storage.URL(ctx, p.StorageKey) if err != nil { return "", err } diff --git a/backend/internal/photos/worker_test.go b/backend/internal/photos/worker_test.go index bbd5532..3ba70e7 100644 --- a/backend/internal/photos/worker_test.go +++ b/backend/internal/photos/worker_test.go @@ -6,12 +6,25 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + + "shared-album/internal/storage" ) func TestWorkerGeneratesVariantsAndCollects(t *testing.T) { pool := testPool(t) - ctx := context.Background() svc, cl := newTestService(t, pool) + runWorkerFlow(t, pool, svc, cl) +} + +func TestWorkerGeneratesVariantsWithFS(t *testing.T) { + pool := testPool(t) + svc, cl := newTestServiceFS(t, pool) + runWorkerFlow(t, pool, svc, cl) +} + +func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend) { + t.Helper() + ctx := context.Background() user := createUser(t, ctx, pool) album := createAlbum(t, ctx, pool, user) diff --git a/backend/internal/storage/fs.go b/backend/internal/storage/fs.go new file mode 100644 index 0000000..83e8cc8 --- /dev/null +++ b/backend/internal/storage/fs.go @@ -0,0 +1,201 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +// errInvalidKey is returned for object keys that are empty, absolute or attempt +// to escape the storage root. +var errInvalidKey = errors.New("fs: invalid object key") + +// FS is a Backend backed by a local directory tree. Blobs live under Root at the +// path formed by the opaque key. Because a local directory has no external URL, +// FS serves blobs through the app using the shared Signer. +type FS struct { + root string + signer *Signer +} + +// NewFS builds a local file-system storage backend rooted at root. signer issues +// and validates the app URLs used to serve blobs. +func NewFS(root string, signer *Signer) (*FS, error) { + if strings.TrimSpace(root) == "" { + return nil, errors.New("fs: root is required") + } + if signer == nil { + return nil, errors.New("fs: signer is required") + } + abs, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("fs: resolve root: %w", err) + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, fmt.Errorf("fs: create root: %w", err) + } + return &FS{root: abs, signer: signer}, nil +} + +func (f *FS) Bucket() string { return f.root } + +func (f *FS) EnsureBucket(context.Context) error { + return os.MkdirAll(f.root, 0o755) +} + +func (f *FS) Ping(context.Context) error { + info, err := os.Stat(f.root) + if err != nil { + return err + } + if !info.IsDir() { + return errors.New("fs: root is not a directory") + } + return nil +} + +// objectPath maps an opaque key to an absolute path inside the root, rejecting +// absolute keys and any key that resolves outside the root. +func (f *FS) objectPath(key string) (string, error) { + if key == "" || filepath.IsAbs(key) { + return "", errInvalidKey + } + clean := filepath.Clean(key) + if clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return "", errInvalidKey + } + p := filepath.Join(f.root, clean) + rel, err := filepath.Rel(f.root, p) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errInvalidKey + } + return p, nil +} + +func (f *FS) Put(_ context.Context, key string, body io.Reader, _ int64, _ string) error { + p, err := f.objectPath(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return fmt.Errorf("fs: mkdir: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(p), ".upload-*") + if err != nil { + return fmt.Errorf("fs: temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + if _, err := io.Copy(tmp, body); err != nil { + _ = tmp.Close() + return fmt.Errorf("fs: write: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("fs: close: %w", err) + } + if err := os.Rename(tmpName, p); err != nil { + return fmt.Errorf("fs: rename: %w", err) + } + return nil +} + +func (f *FS) Get(_ context.Context, key string) ([]byte, error) { + p, err := f.objectPath(key) + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrObjectNotFound + } + return nil, err + } + return data, nil +} + +func (f *FS) Delete(_ context.Context, keys ...string) error { + var errs []error + for _, key := range keys { + p, err := f.objectPath(key) + if err != nil { + errs = append(errs, err) + continue + } + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// URL returns a signed app URL served by this process. +func (f *FS) URL(_ context.Context, key string) (string, error) { + if _, err := f.objectPath(key); err != nil { + return "", err + } + return f.signer.URL(key), nil +} + +// ServeObject validates the signed URL and serves the blob. It supports range +// requests and sets the content type from the key extension. It writes the +// response itself and returns nil on success. +func (f *FS) ServeObject(w http.ResponseWriter, r *http.Request, key string) error { + if unescaped, err := url.PathUnescape(key); err == nil { + key = unescaped + } + if err := f.signer.VerifyRequest(key, r); err != nil { + return err + } + p, err := f.objectPath(key) + if err != nil { + return ErrSignedURLInvalid + } + file, err := os.Open(p) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrObjectNotFound + } + return err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return err + } + w.Header().Set("Content-Type", contentTypeForKey(key)) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) + return nil +} + +var imageContentTypes = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".heic": "image/heic", + ".heif": "image/heif", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".bmp": "image/bmp", + ".avif": "image/avif", +} + +func contentTypeForKey(key string) string { + ext := strings.ToLower(filepath.Ext(key)) + if ct, ok := imageContentTypes[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} diff --git a/backend/internal/storage/fs_test.go b/backend/internal/storage/fs_test.go new file mode 100644 index 0000000..1a4ce8f --- /dev/null +++ b/backend/internal/storage/fs_test.go @@ -0,0 +1,174 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" +) + +func newTestFS(t *testing.T) *FS { + t.Helper() + signer, err := NewSigner("http://app.test", "test-secret", time.Hour) + if err != nil { + t.Fatalf("signer: %v", err) + } + fs, err := NewFS(t.TempDir(), signer) + if err != nil { + t.Fatalf("new fs: %v", err) + } + return fs +} + +func TestFSRoundTrip(t *testing.T) { + ctx := context.Background() + fs := newTestFS(t) + key := "objects/abc/original.jpg" + data := []byte("hello blob") + + if err := fs.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + got, err := fs.Get(ctx, key) + if err != nil { + t.Fatalf("get: %v", err) + } + if !bytes.Equal(got, data) { + t.Fatalf("get = %q, want %q", got, data) + } + if err := fs.Delete(ctx, key); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := fs.Get(ctx, key); !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("get after delete = %v, want ErrObjectNotFound", err) + } +} + +func TestFSPathTraversalRejected(t *testing.T) { + ctx := context.Background() + fs := newTestFS(t) + for _, key := range []string{"../escape", "../../etc/passwd", "/etc/passwd", ""} { + if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err == nil { + t.Errorf("put %q: expected rejection", key) + } + if _, err := fs.Get(ctx, key); err == nil { + t.Errorf("get %q: expected rejection", key) + } + if _, err := fs.URL(ctx, key); err == nil { + t.Errorf("url %q: expected rejection", key) + } + } +} + +func TestFSURLAndServe(t *testing.T) { + ctx := context.Background() + fs := newTestFS(t) + key := "objects/abc/thumb.webp" + if err := fs.Put(ctx, key, strings.NewReader("webp-bytes"), 10, "image/webp"); err != nil { + t.Fatalf("put: %v", err) + } + + raw, err := fs.URL(ctx, key) + if err != nil { + t.Fatalf("url: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse url: %v", err) + } + if !strings.HasPrefix(u.Path, "/api/objects/objects/abc/thumb.webp") { + t.Fatalf("unexpected path: %s", u.Path) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, raw, nil) + if err := fs.ServeObject(rec, req, strings.TrimPrefix(u.Path, "/api/objects/")); err != nil { + t.Fatalf("serve: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "image/webp" { + t.Fatalf("content-type = %q, want image/webp", ct) + } + if rec.Body.String() != "webp-bytes" { + t.Fatalf("body = %q", rec.Body.String()) + } +} + +func TestFSServeExpiredAndForged(t *testing.T) { + ctx := context.Background() + fs := newTestFS(t) + key := "objects/abc/original.jpg" + if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + + past := time.Now().Add(-time.Minute).Unix() + expired := "/api/objects/" + key + "?e=" + strconv.FormatInt(past, 10) + "&s=" + fs.signer.sign(key, past) + rec := httptest.NewRecorder() + err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, expired, nil), key) + if !errors.Is(err, ErrSignedURLInvalid) { + t.Fatalf("expired = %v, want ErrSignedURLInvalid", err) + } + + future := time.Now().Add(time.Hour).Unix() + forged := "/api/objects/" + key + "?e=" + strconv.FormatInt(future, 10) + "&s=deadbeef" + rec = httptest.NewRecorder() + err = fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, forged, nil), key) + if !errors.Is(err, ErrSignedURLInvalid) { + t.Fatalf("forged = %v, want ErrSignedURLInvalid", err) + } +} + +func TestFSServeRangeAndMissing(t *testing.T) { + ctx := context.Background() + fs := newTestFS(t) + key := "objects/abc/original.jpg" + if err := fs.Put(ctx, key, strings.NewReader("0123456789"), 10, "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + exp := time.Now().Add(time.Hour).Unix() + base := "/api/objects/" + key + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(key, exp) + + req := httptest.NewRequest(http.MethodGet, base, nil) + req.Header.Set("Range", "bytes=2-5") + rec := httptest.NewRecorder() + if err := fs.ServeObject(rec, req, key); err != nil { + t.Fatalf("serve range: %v", err) + } + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if body, _ := io.ReadAll(rec.Body); string(body) != "2345" { + t.Fatalf("range body = %q, want 2345", body) + } + + missingKey := "objects/def/original.jpg" + missing := "/api/objects/" + missingKey + "?e=" + strconv.FormatInt(exp, 10) + "&s=" + fs.signer.sign(missingKey, exp) + rec = httptest.NewRecorder() + err := fs.ServeObject(rec, httptest.NewRequest(http.MethodGet, missing, nil), missingKey) + if !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("missing = %v, want ErrObjectNotFound", err) + } +} + +func TestContentTypeForKey(t *testing.T) { + cases := map[string]string{ + "objects/a/original.jpg": "image/jpeg", + "objects/a/thumb.webp": "image/webp", + "objects/a/odd.unknownext": "application/octet-stream", + } + for key, want := range cases { + if got := contentTypeForKey(key); got != want { + t.Errorf("contentTypeForKey(%q) = %q, want %q", key, got, want) + } + } +} diff --git a/backend/internal/storage/s3_test.go b/backend/internal/storage/s3_test.go new file mode 100644 index 0000000..0e4bf4f --- /dev/null +++ b/backend/internal/storage/s3_test.go @@ -0,0 +1,142 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "shared-album/internal/config" +) + +func newTestS3(t *testing.T) (*S3, *Signer) { + t.Helper() + endpoint := os.Getenv("S3_ENDPOINT") + if endpoint == "" { + t.Skip("S3_ENDPOINT not set") + } + signer, err := NewSigner("http://app.test", "secret", time.Hour) + if err != nil { + t.Fatalf("signer: %v", err) + } + cl, err := NewS3(context.Background(), config.S3{ + Endpoint: endpoint, + Region: os.Getenv("S3_REGION"), + AccessKey: os.Getenv("S3_ACCESS_KEY"), + SecretKey: os.Getenv("S3_SECRET_KEY"), + Bucket: os.Getenv("S3_BUCKET"), + UsePathStyle: true, + }, signer) + if err != nil { + t.Skipf("storage init: %v", err) + } + return cl, signer +} + +func signedQuery(signer *Signer, key string, exp int64) string { + return "?e=" + strconv.FormatInt(exp, 10) + "&s=" + signer.sign(key, exp) +} + +func TestS3ServeObject(t *testing.T) { + ctx := context.Background() + cl, signer := newTestS3(t) + key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" + data := []byte("0123456789") + if err := cl.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + t.Cleanup(func() { _ = cl.Delete(context.Background(), key) }) + + exp := time.Now().Add(time.Hour).Unix() + base := "/api/objects/" + key + signedQuery(signer, key, exp) + + t.Run("full", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, base, nil) + if err := cl.ServeObject(rec, req, key); err != nil { + t.Fatalf("serve: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), data) { + t.Fatalf("body = %q", rec.Body.String()) + } + }) + + t.Run("range", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, base, nil) + req.Header.Set("Range", "bytes=2-5") + if err := cl.ServeObject(rec, req, key); err != nil { + t.Fatalf("serve range: %v", err) + } + if rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 206", rec.Code) + } + if body, _ := io.ReadAll(rec.Body); string(body) != "2345" { + t.Fatalf("range body = %q, want 2345", body) + } + }) + + t.Run("invalid range", func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, base, nil) + req.Header.Set("Range", "bytes=1000-2000") + err := cl.ServeObject(rec, req, key) + if !errors.Is(err, ErrRangeNotSatisfiable) { + t.Fatalf("err = %v, want ErrRangeNotSatisfiable", err) + } + }) + + t.Run("missing", func(t *testing.T) { + missingKey := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" + target := "/api/objects/" + missingKey + signedQuery(signer, missingKey, exp) + rec := httptest.NewRecorder() + err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), missingKey) + if !errors.Is(err, ErrObjectNotFound) { + t.Fatalf("err = %v, want ErrObjectNotFound", err) + } + }) + + t.Run("expired signature", func(t *testing.T) { + past := time.Now().Add(-time.Minute).Unix() + target := "/api/objects/" + key + signedQuery(signer, key, past) + rec := httptest.NewRecorder() + err := cl.ServeObject(rec, httptest.NewRequest(http.MethodGet, target, nil), key) + if !errors.Is(err, ErrSignedURLInvalid) { + t.Fatalf("err = %v, want ErrSignedURLInvalid", err) + } + }) +} + +func TestS3ServeObjectHead(t *testing.T) { + ctx := context.Background() + cl, signer := newTestS3(t) + key := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/original.jpg" + data := []byte("head-check") + if err := cl.Put(ctx, key, strings.NewReader(string(data)), int64(len(data)), "image/jpeg"); err != nil { + t.Fatalf("put: %v", err) + } + t.Cleanup(func() { _ = cl.Delete(context.Background(), key) }) + + exp := time.Now().Add(time.Hour).Unix() + target := "/api/objects/" + key + signedQuery(signer, key, exp) + rec := httptest.NewRecorder() + if err := cl.ServeObject(rec, httptest.NewRequest(http.MethodHead, target, nil), key); err != nil { + t.Fatalf("head: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("HEAD body should be empty, got %d bytes", rec.Body.Len()) + } +} diff --git a/backend/internal/storage/signer.go b/backend/internal/storage/signer.go new file mode 100644 index 0000000..79f20b7 --- /dev/null +++ b/backend/internal/storage/signer.go @@ -0,0 +1,80 @@ +package storage + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Signer produces and verifies the signed app URLs used by the /api/objects +// route. It is shared by every storage backend so delivery is identical: the +// client always talks to the backend, never to object storage directly. +type Signer struct { + secret []byte + baseURL string + ttl time.Duration +} + +// NewSigner builds a signer. secret signs the URLs (required); baseURL is the +// public base URL of the app; ttl limits URL lifetime (defaults to one hour). +func NewSigner(baseURL, secret string, ttl time.Duration) (*Signer, error) { + if strings.TrimSpace(secret) == "" { + return nil, errors.New("url secret is required") + } + if ttl <= 0 { + ttl = time.Hour + } + return &Signer{ + secret: []byte(secret), + baseURL: strings.TrimRight(baseURL, "/"), + ttl: ttl, + }, nil +} + +// URL returns a signed app URL for the given opaque key. +func (s *Signer) URL(key string) string { + exp := time.Now().Add(s.ttl).Unix() + q := url.Values{} + q.Set("e", strconv.FormatInt(exp, 10)) + q.Set("s", s.sign(key, exp)) + return fmt.Sprintf("%s/api/objects/%s?%s", s.baseURL, escapeKey(key), q.Encode()) +} + +// VerifyRequest checks the signature and expiry carried by an object request. +// It returns ErrSignedURLInvalid when the request is not authorized. +func (s *Signer) VerifyRequest(key string, r *http.Request) error { + q := r.URL.Query() + exp, err := strconv.ParseInt(q.Get("e"), 10, 64) + if err != nil || !s.verify(key, exp, q.Get("s")) { + return ErrSignedURLInvalid + } + return nil +} + +func (s *Signer) sign(key string, exp int64) string { + mac := hmac.New(sha256.New, s.secret) + _, _ = fmt.Fprintf(mac, "%s\n%d", key, exp) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Signer) verify(key string, exp int64, sig string) bool { + if sig == "" || time.Now().Unix() > exp { + return false + } + return hmac.Equal([]byte(s.sign(key, exp)), []byte(sig)) +} + +func escapeKey(key string) string { + parts := strings.Split(key, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 91de32e..6cce96f 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -5,8 +5,9 @@ import ( "errors" "fmt" "io" + "net/http" + "strconv" "strings" - "time" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -18,14 +19,49 @@ import ( "shared-album/internal/config" ) -type Client struct { - s3 *s3.Client - presign *s3.PresignClient - bucket string - presignTTL time.Duration +// ErrSignedURLInvalid is returned when an app-served object URL has a missing, +// malformed, expired or forged signature. +var ErrSignedURLInvalid = errors.New("invalid or expired object signature") + +// ErrObjectNotFound is returned when the requested blob does not exist. +var ErrObjectNotFound = errors.New("object not found") + +// ErrRangeNotSatisfiable is returned when a requested byte range is invalid. +var ErrRangeNotSatisfiable = errors.New("requested range not satisfiable") + +// Backend is the object storage abstraction. Keys are opaque and independent of +// the backend: every implementation stores and serves the same logical blob. +// Blobs are always delivered through the app. +type Backend interface { + Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error + Delete(ctx context.Context, keys ...string) error + Get(ctx context.Context, key string) ([]byte, error) + // URL returns a time-limited signed app URL the client can use to fetch the blob. + URL(ctx context.Context, key string) (string, error) + // ServeObject validates the signed URL and writes the blob, including + // Content-Type and range handling, to the response. + ServeObject(w http.ResponseWriter, r *http.Request, key string) error + Ping(ctx context.Context) error + EnsureBucket(ctx context.Context) error + // Bucket returns a human-readable identifier for the storage location. + Bucket() string } -func New(ctx context.Context, cfg config.S3) (*Client, error) { +// S3 is a Backend backed by an S3-compatible object store (Garage). Blobs are +// delivered through the app: the client receives a signed app URL and the bytes +// are streamed from S3 over the internal network. +type S3 struct { + s3 *s3.Client + bucket string + signer *Signer +} + +// NewS3 builds an S3-compatible storage backend. signer issues the app URLs used +// to serve blobs. +func NewS3(ctx context.Context, cfg config.S3, signer *Signer) (*S3, error) { + if signer == nil { + return nil, errors.New("s3: signer is required") + } awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.Region), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( @@ -41,17 +77,38 @@ func New(ctx context.Context, cfg config.S3) (*Client, error) { o.UsePathStyle = cfg.UsePathStyle }) - return &Client{ - s3: client, - presign: s3.NewPresignClient(client), - bucket: cfg.Bucket, - presignTTL: cfg.PresignTTL, + return &S3{ + s3: client, + bucket: cfg.Bucket, + signer: signer, }, nil } -func (c *Client) Bucket() string { return c.bucket } +func (c *S3) Bucket() string { return c.bucket } -func (c *Client) EnsureBucket(ctx context.Context) error { +// Open builds the storage backend selected by configuration: S3-compatible by +// default, or the local file system when STORAGE_BACKEND=fs. Every backend +// delivers blobs through the app using a shared Signer. +func Open(ctx context.Context, cfg *config.Config) (Backend, error) { + secret := cfg.Storage.URLSecret + if secret == "" { + secret = cfg.Auth.JWTSecret + } + signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL) + if err != nil { + return nil, err + } + switch cfg.Storage.Backend { + case "", "s3": + return NewS3(ctx, cfg.S3, signer) + case "fs": + return NewFS(cfg.Storage.FSRoot, signer) + default: + return nil, fmt.Errorf("unknown storage backend %q", cfg.Storage.Backend) + } +} + +func (c *S3) EnsureBucket(ctx context.Context) error { _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)}) if err == nil { return nil @@ -66,12 +123,12 @@ func (c *Client) EnsureBucket(ctx context.Context) error { return nil } -func (c *Client) Ping(ctx context.Context) error { +func (c *S3) Ping(ctx context.Context) error { _, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)}) return err } -func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { +func (c *S3) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { in := &s3.PutObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), @@ -87,7 +144,7 @@ func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64 return nil } -func (c *Client) Delete(ctx context.Context, keys ...string) error { +func (c *S3) Delete(ctx context.Context, keys ...string) error { if len(keys) == 0 { return nil } @@ -104,7 +161,7 @@ func (c *Client) Delete(ctx context.Context, keys ...string) error { return nil } -func (c *Client) Get(ctx context.Context, key string) ([]byte, error) { +func (c *S3) Get(ctx context.Context, key string) ([]byte, error) { out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), @@ -120,14 +177,97 @@ func (c *Client) Get(ctx context.Context, key string) ([]byte, error) { return data, nil } -func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{ +// URL returns a signed app URL. The blob itself is streamed by ServeObject, so +// the S3 endpoint is never exposed to the client. +func (c *S3) URL(_ context.Context, key string) (string, error) { + return c.signer.URL(key), nil +} + +// ServeObject validates the signed URL and streams the blob from S3. It passes +// the client's Range header through to S3, so byte ranges are handled natively, +// and answers HEAD via HeadObject. It writes the response itself. +func (c *S3) ServeObject(w http.ResponseWriter, r *http.Request, key string) error { + if err := c.signer.VerifyRequest(key, r); err != nil { + return err + } + if r.Method == http.MethodHead { + return c.headObject(w, r, key) + } + in := &s3.GetObjectInput{Bucket: aws.String(c.bucket), Key: aws.String(key)} + if rng := r.Header.Get("Range"); rng != "" { + in.Range = aws.String(rng) + } + out, err := c.s3.GetObject(r.Context(), in) + if err != nil { + if isNoSuchKey(err) { + return ErrObjectNotFound + } + if isInvalidRange(err) { + return ErrRangeNotSatisfiable + } + return err + } + defer out.Body.Close() + if ct := aws.ToString(out.ContentType); ct != "" { + w.Header().Set("Content-Type", ct) + } else { + w.Header().Set("Content-Type", contentTypeForKey(key)) + } + if out.ContentLength != nil { + w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10)) + } + if out.ContentRange != nil { + w.Header().Set("Content-Range", *out.ContentRange) + } + status := http.StatusOK + if out.ContentRange != nil { + status = http.StatusPartialContent + } + w.WriteHeader(status) + if _, err := io.Copy(w, out.Body); err != nil { + return err + } + return nil +} + +func (c *S3) headObject(w http.ResponseWriter, r *http.Request, key string) error { + out, err := c.s3.HeadObject(r.Context(), &s3.HeadObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), - }, s3.WithPresignExpires(c.presignTTL)) + }) if err != nil { - return "", fmt.Errorf("presign %s: %w", key, err) + if isNoSuchKey(err) { + return ErrObjectNotFound + } + return err } - return out.URL, nil + if ct := aws.ToString(out.ContentType); ct != "" { + w.Header().Set("Content-Type", ct) + } else { + w.Header().Set("Content-Type", contentTypeForKey(key)) + } + if out.ContentLength != nil { + w.Header().Set("Content-Length", strconv.FormatInt(*out.ContentLength, 10)) + } + w.WriteHeader(http.StatusOK) + return nil +} + +func isNoSuchKey(err error) bool { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + code := apiErr.ErrorCode() + return strings.EqualFold(code, "NoSuchKey") || strings.EqualFold(code, "NotFound") + } + return false +} + +func isInvalidRange(err error) bool { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return strings.EqualFold(apiErr.ErrorCode(), "InvalidRange") + } + return false } func isNoSuchBucket(err error) bool { diff --git a/deploy/nginx-sse.conf b/deploy/nginx-sse.conf index 8376c7a..19e08ed 100644 --- a/deploy/nginx-sse.conf +++ b/deploy/nginx-sse.conf @@ -21,6 +21,14 @@ server { chunked_transfer_encoding on; } + location /api/objects/ { + proxy_pass http://shared_album; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_buffering off; # стриминг объектов без буферизации + proxy_cache off; + } + location / { proxy_pass http://shared_album; proxy_http_version 1.1; diff --git a/openspec/changes/local-fs-storage-backend/.openspec.yaml b/openspec/changes/local-fs-storage-backend/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/local-fs-storage-backend/design.md b/openspec/changes/local-fs-storage-backend/design.md new file mode 100644 index 0000000..f72c310 --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/design.md @@ -0,0 +1,107 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее состояние, определяющее подход: + +- `storage.Backend` уже введён; `storage.S3` возвращает presigned URL на Garage, а + `storage.FS` отдаёт блобы через приложение. Presign уводит браузер напрямую в S3. +- В проде Garage (`s3.localnet`) недоступен извне, поэтому presigned URL не работают: + отдача обязана идти через бэкенд для обоих бэкендов. +- Подпись URL сейчас живёт внутри `FS` (`fs.go: sign/verify/URL`). +- Маршрут `/api/objects/*key` и `app.serveObject` уже есть и диспетчеризуются по + необязательному интерфейсу `storage.ObjectServer`. +- Ключи объектов непрозрачны (`objects/{id}/{role}{ext}`) и одинаковы для всех бэкендов. +- Сборка `CGO_ENABLED=0`, один бинарник + systemd, перед Go стоит nginx. + +## Goals / Non-Goals + +**Goals:** +- Единая модель доставки: любой блоб клиент получает только через бэкенд. +- Убрать presigned URL полностью; S3 отдаёт байты через прокси бэкенда. +- Сохранить выбор бэкенда хранилища (`s3|fs`) и непрозрачные ключи. +- Сохранить работу анонимных share-ссылок без пользовательской сессии. + +**Non-Goals:** +- Миграция существующих блобов между бэкендами. +- Переключение бэкенда в рантайме. +- Кэширующий CDN и раздача через внешний веб-сервер. +- Изменение слоя метаданных (см. `sqlite-metadata-backend`). + +## Decisions + +### D1. `ServeObject` — обязательная часть `storage.Backend` +Отдача через бэкенд нужна всем бэкендам, поэтому `ServeObject(w, r, key)` входит в +интерфейс `Backend`, а не остаётся опциональным `ObjectServer`. Компилятор не даст +собрать бэкенд без отдачи, а `app.serveObject` диспетчеризуется без type-assert. +- Альтернатива: оставить `ObjectServer` опциональным — теперь это всегда реализуется, + опциональность только маскирует ошибку. + +### D2. `URL` всегда возвращает подписанный URL приложения +`Backend.URL` больше не зависит от бэкенда в способе доставки: и `s3`, и `fs` возвращают +`${APP_BASE_URL}/api/objects/?e=&s=`. Presign-клиент AWS удаляется. +- Альтернатива (dev): оставить presign для S3 — отвергнута по требованию «только через + бэкенд» и чтобы не иметь двух режимов доставки. + +### D3. Общий `Signer` +HMAC-SHA256 от `key + exp` выносится в общий `Signer` (секрет `URL_SECRET` с fallback на +`JWT_SECRET`, срок `OBJECT_URL_TTL`). Его используют и FS, и S3, и маршрут проверяет +подпись одинаково. Внутри `FS` дублирующая подпись удаляется. +- Альтернатива: дублировать HMAC в каждом бэкенде — риск рассинхрона проверки. + +### D4. Отдача S3: ranged `GetObject` и стриминг +S3-бэкенд в `ServeObject` определяет `Range` из запроса, вызывает `GetObject` с тем же +диапазоном (Garage отдаёт `Content-Range`) и стримит тело в ответ через `io.Copy` +(200/206/416). HEAD обслуживается `HeadObject`. Память не растёт, размер берётся из +`Content-Length` ответа S3. +- Альтернатива: `http.ServeContent` требует `io.ReadSeeker`; для S3 это ranged-GET-адаптер + и лишний size-probe — отвергнуто ради простоты нативного passthrough. +- `S3.Get` (в память) сохраняется только для воркера/backfill, отдачу не использует. + +### D5. Отдача FS: без изменений +`FS.ServeObject` остаётся на `http.ServeContent` (Range и Content-Type бесплатно). +Меняется лишь источник подписи — общий `Signer`. + +### D6. Публичный маршрут и доступ +`/api/objects/*key` вне группы авторизации; подпись и срок — единственный контроль +доступа, поэтому он обслуживает и авторизованных, и анонимные share-ссылки. Наружу не +уходят ни S3-endpoint, ни AWS-подпись, ни ключ с секретом. + +### D7. Конфигурация +`STORAGE_BACKEND=s3|fs` без изменений (дефолт `s3`). `FS_ROOT` — для `fs`. +`S3_PRESIGN_TTL` переименовывается в `OBJECT_URL_TTL` (семантика: срок подписи URL). +`S3_PUBLIC_URL` удаляется как неиспользуемая. `APP_BASE_URL` становится определяющим для +корректности ссылок; пустое значение даёт относительный `/api/objects/...` (полагается на +nginx того же origin). + +### D8. nginx для прокси-отдачи +`location /api/objects/` обслуживается как `location /`, но с `proxy_buffering off` и +`X-Accel-Buffering: no`, чтобы стриминг и Range не буферизовались. SSE-правило остаётся. + +## Risks / Trade-offs + +- [Все байты медиа идут через Go] → стриминг без чтения в память; `proxy_buffering off`; + для крупных инсталляций это принятая плата за требование «только через бэкенд». +- [Подписанный URL действует как bearer до истечения] → срок `OBJECT_URL_TTL` (1 час), + отдельный `URL_SECRET` для отзыва. +- [Range/HEAD через прокси] → passthrough `Range` в S3 и `HeadObject` для HEAD; покрыто + httptest-тестами (200/206/416/404). +- [Ломающее переименование `S3_PRESIGN_TTL`] → задокументировать в `.env.example` и + `project.md`; при откате вернуть имя. +- [Обход каталога через ключ FS] → нормализация пути и проверка префикса `FS_ROOT`. +- [Смена бэкенда на существующих данных не переносит блобы] → документируем; миграция вне + рамок. + +## Migration Plan + +1. Вынести `Signer`, сделать `ServeObject` обязательным, `URL` — всегда app-signed. +2. Реализовать S3 `ServeObject` (ranged `GetObject` + стриминг, HEAD). +3. Удалить presign-путь и `S3_PUBLIC_URL`; переименовать TTL; обновить доки и nginx. +4. Существующие развёртывания: дефолт `s3` сохраняется, но доставка теперь проксируется — + внешний доступ к S3 больше не нужен. +5. Перенос блобов между бэкендами не автоматизируется. + +## Open Questions + +- Нужно ли выставлять `Cache-Control` на ответы `/api/objects/` помимо браузерного кэша + по уникальному URL. diff --git a/openspec/changes/local-fs-storage-backend/proposal.md b/openspec/changes/local-fs-storage-backend/proposal.md new file mode 100644 index 0000000..8505cc7 --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/proposal.md @@ -0,0 +1,40 @@ +# Proposal + +## Why + +S3-совместимое хранилище (Garage на `s3.localnet`) в проде недоступно извне, поэтому +отдача медиа не может идти напрямую из хранилища в браузер. Нужен слой хранения, +который всегда отдаёт блобы через бэкенд, и локальный бэкенд для инсталляций без S3. + +## What Changes + +- Ввести интерфейс `storage.Backend`: Put/Delete/Get/Ping/EnsureBucket/Bucket, `URL` + (всегда подписанный URL приложения) и обязательный `ServeObject`. +- Реализовать бэкенд локальной ФС: блобы под корневым каталогом по непрозрачным ключам + `objects/{id}/{role}{ext}`, защита от обхода пути, отдача через `ServeContent`. +- Реализовать отдачу S3 через бэкенд: `ServeObject` тянет объект ranged `GetObject` из + Garage (внутренняя сеть) и стримит браузеру; presigned URL больше не используются. +- Публичный маршрут `/api/objects/` с HMAC-подписью, сроком, Range и Content-Type + обслуживает и авторизованных, и анонимные share-ссылки. +- Ввести `STORAGE_BACKEND=s3|fs` и `FS_ROOT`; убрать `S3_PUBLIC_URL`; переименовать + `S3_PRESIGN_TTL` → `OBJECT_URL_TTL`; дефолт — `s3`. +- Сделать метку хранилища в health динамической. +- Сделать формулировки отдачи файлов нейтральными к бэкенду в спеках `photos` и `share-links`. + +## Capabilities + +### New Capabilities +- `storage-backends`: выбор и работа объектного хранилища — S3-совместимое (Garage) и локальная ФС; блобы всегда отдаются через бэкенд по подписанному URL, хранилище клиенту не раскрывается. + +### Modified Capabilities +- `photos`: получение фото и скачивание не требуют presigned URL и обслуживаются бэкендом (подписанный URL). +- `share-links`: просмотр по ссылке отдаёт подписанный URL, обслуживаемый бэкендом. + +## Impact + +- `backend/internal/storage` (интерфейс, общий `Signer`, FS-бэкенд, S3-proxy-отдача), + сервисы `photos` и `albums`, `app` (маршрут отдачи, health), `cmd/*`, `config`. +- Удаление presign-пути AWS SDK из `storage`. +- `.env.example`, `project.md`, `deploy/nginx-sse.conf`. +- Спеки `photos`, `share-links`, новая `storage-backends`. +- Не затрагивает слой метаданных. diff --git a/openspec/changes/local-fs-storage-backend/specs/photos/spec.md b/openspec/changes/local-fs-storage-backend/specs/photos/spec.md new file mode 100644 index 0000000..de0824d --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/specs/photos/spec.md @@ -0,0 +1,31 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Получение фото по требованию +Клиент SHALL иметь возможность получить актуальные данные фото с подписанным URL, +обслуживаемым бэкендом, независимо от бэкенда хранения. + +#### Scenario: Выдача свежего URL +- **WHEN** клиент запрашивает фото по id +- **THEN** система возвращает свежий подписанный URL бэкенда (срок до 1 часа) для просмотра + +### Requirement: Скачивание одиночного фото +Любой участник, включая viewer, SHALL иметь возможность скачать одиночное фото. + +#### Scenario: Скачивание фото +- **WHEN** участник запрашивает скачивание фото +- **THEN** система возвращает подписанный URL оригинала, обслуживаемый бэкендом + +### Requirement: Перенос и копирование фото между альбомами +Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь +возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только +метаданные и SHALL не изменять объекты хранилища. + +#### Scenario: Перенос +- **WHEN** пользователь переносит фото в другой альбом +- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты хранилища не меняются + +#### Scenario: Копирование +- **WHEN** пользователь копирует фото в другой альбом +- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты хранилища diff --git a/openspec/changes/local-fs-storage-backend/specs/share-links/spec.md b/openspec/changes/local-fs-storage-backend/specs/share-links/spec.md new file mode 100644 index 0000000..3dfe634 --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/specs/share-links/spec.md @@ -0,0 +1,14 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Просмотр альбома по ссылке +Пользователь без авторизации SHALL иметь возможность просматривать альбом по действующему токену (сетка, лайтбокс, скачивание одиночного фото). + +#### Scenario: Просмотр по действующей ссылке +- **WHEN** аноним открывает ссылку с действующим токеном +- **THEN** система возвращает фото альбома со свежими подписанными URL, обслуживаемыми бэкендом + +#### Scenario: Истёкшая или отозванная ссылка +- **WHEN** аноним открывает ссылку с истёкшим или отозванным токеном +- **THEN** система отклоняет доступ diff --git a/openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md b/openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md new file mode 100644 index 0000000..03092da --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md @@ -0,0 +1,108 @@ +# Spec Delta + +## Purpose + +Выбор и работа объектного хранилища: S3-совместимое (Garage) или локальная файловая +система. Блобы всегда отдаются через бэкенд по подписанному URL, хранилище клиенту не +раскрывается. + +## ADDED Requirements + +### Requirement: Выбор бэкенда хранилища +Система SHALL поддерживать выбор бэкенда объектного хранилища параметром конфигурации, +при этом S3-совместимый бэкенд SHALL оставаться значением по умолчанию. + +#### Scenario: Выбор S3-совместимого бэкенда +- **WHEN** в конфигурации задан S3-совместимый бэкенд +- **THEN** система хранит и отдаёт блобы через S3-совместимое хранилище + +#### Scenario: Выбор локальной ФС +- **WHEN** в конфигурации задан бэкенд локальной ФС +- **THEN** система хранит и отдаёт блобы из каталога локальной файловой системы + +#### Scenario: Неполная конфигурация +- **WHEN** конфигурация выбранного бэкенда неполна +- **THEN** система отказывается стартовать с явной ошибкой конфигурации + +### Requirement: Единая абстракция хранилища +Операции записи, чтения, удаления и проверки доступности SHALL выполняться через единый +интерфейс хранилища, одинаковый для всех бэкендов; ключи объектов SHALL оставаться +непрозрачными и независимыми от бэкенда. + +#### Scenario: Ключи переносимы между бэкендами +- **WHEN** один и тот же ключ объекта используется на разных бэкендах +- **THEN** он адресует тот же логический блоб без изменения формата + +#### Scenario: Удаление объекта +- **WHEN** система удаляет объект через интерфейс хранилища +- **THEN** блоб удаляется выбранным бэкендом + +### Requirement: Отдача блобов через бэкенд +Клиент SHALL получать любой блоб по URL, обслуживаемому бэкендом, с ограниченным сроком +действия; ни один бэкенд SHALL не отдавать клиенту ссылку напрямую на объектное +хранилище. Для анонимного доступа по share-ссылке подписанный URL SHALL быть действителен +без пользовательской сессии. + +#### Scenario: Свежий URL просмотра +- **WHEN** клиент запрашивает фото +- **THEN** система возвращает подписанный URL бэкенда со сроком действия до 1 часа + +#### Scenario: Доступ по share-ссылке +- **WHEN** аноним открывает альбом по действующей share-ссылке +- **THEN** полученные подписанные URL позволяют загрузить фото без сессии + +#### Scenario: Истёкшая подпись +- **WHEN** клиент обращается к URL с истёкшей или неверной подписью +- **THEN** система отказывает в доступе + +### Requirement: Хранилище не раскрывается клиенту +Адрес и учётные данные объектного хранилища SHALL оставаться внутренними; клиент SHALL +взаимодействовать с медиа только через бэкенд, независимо от бэкенда хранения. + +#### Scenario: S3-совместимый бэкенд +- **WHEN** хранилищем выбран S3-совместимый бэкенд +- **THEN** выдаваемый клиенту URL указывает на бэкенд, а не на S3-эндпоинт + +#### Scenario: Локальная ФС +- **WHEN** хранилищем выбрана локальная ФС +- **THEN** выдаваемый клиенту URL указывает на бэкенд + +#### Scenario: Хранилище недоступно извне +- **WHEN** объектное хранилище недоступно из внешней сети +- **THEN** просмотр и скачивание медиа продолжают работать + +### Requirement: Локальное хранение на ФС +Бэкенд локальной ФС SHALL хранить блобы в пределах заданного корневого каталога по +непрозрачным ключам объектов и SHALL отвергать ключи, выводящие за пределы корня. + +#### Scenario: Запись и чтение блоба +- **WHEN** система сохраняет объект с ключом +- **THEN** блоб размещается под корневым каталогом и позже читается по тому же ключу + +#### Scenario: Попытка выхода из корня +- **WHEN** ключ содержит элементы обхода каталога +- **THEN** система отвергает операцию и не обращается за пределы корня + +### Requirement: Диапазонные запросы и тип содержимого +При отдаче блоба система SHALL возвращать корректный тип содержимого и SHALL +поддерживать диапазонные запросы для докачки и больших файлов. + +#### Scenario: Запрос диапазона +- **WHEN** клиент запрашивает часть блоба через диапазон +- **THEN** система возвращает запрошенный диапазон и соответствующий статус + +#### Scenario: Тип содержимого +- **WHEN** клиент загружает блоб +- **THEN** ответ содержит тип содержимого, соответствующий объекту + +### Requirement: Доступность выбранного хранилища +Система SHALL сообщать о доступности выбранного бэкенда хранилища и отражать его в +состоянии здоровья сервиса. + +#### Scenario: Хранилище доступно +- **WHEN** выбранный бэкенд отвечает на проверку +- **THEN** состоянии здоровья отражает хранилище как доступное + +#### Scenario: Хранилище недоступно +- **WHEN** выбранный бэкенд не отвечает на проверку +- **THEN** состояние здоровья отражает деградацию diff --git a/openspec/changes/local-fs-storage-backend/tasks.md b/openspec/changes/local-fs-storage-backend/tasks.md new file mode 100644 index 0000000..314aa93 --- /dev/null +++ b/openspec/changes/local-fs-storage-backend/tasks.md @@ -0,0 +1,39 @@ +# Tasks + +## 1. Интерфейс хранилища и перевод S3 + +- [x] 1.1 Ввести интерфейс `storage.Backend` (Put/Delete/Get/Ping/EnsureBucket/Bucket/URL) и сделать S3-клиент его реализацией; проверка — `go build ./...` проходит. +- [x] 1.2 Заменить `PresignGet` на `URL(ctx, key)` во всех вызовах; проверка — `go build ./...` и `go vet ./...` без ошибок. +- [x] 1.3 Перевести `photos.Service`, `albums.Service`, `app.Server` и `cmd/*` на тип `storage.Backend`; проверка — сборка `cmd/server` и `cmd/backfill-exif` успешна. +- [x] 1.4 Прогнать существующие S3-тесты (под `S3_ENDPOINT`); проверка — тесты зелёные или корректно пропущены без env. + +## 2. Бэкенд локальной ФС + +- [x] 2.1 Реализовать `fs.Backend` (Put/Get/Delete/Ping/EnsureBucket/Bucket) с раскладкой `/` и нормализацией пути; проверка — юнит-тесты записи, чтения, удаления и отказа на `..`/абсолютный ключ проходят. +- [x] 2.2 Реализовать `URL` с HMAC-SHA256 подписью (`key`+`exp`, `URL_SECRET` с fallback на `JWT_SECRET`) и сроком `OBJECT_URL_TTL`; проверка — юнит-тесты валидной, истёкшей и подделанной подписи проходят. + +## 3. Маршрут отдачи объектов + +- [x] 3.1 Добавить публичный `GET /api/objects/` с проверкой подписи и срока и стримингом через `http.ServeContent`; проверка — httptest-тесты: валидный доступ (200), истёкшая подпись (403), Range (206), отсутствующий ключ (404). +- [x] 3.2 Проверить вывод `Content-Type` по расширению с fallback `application/octet-stream`; проверка — тест на `.jpg`/`.webp` и неизвестное расширение. +- [x] 3.3 Убедиться, что share-ссылки отдают рабочие подписанные URL на обоих бэкендах; проверка — интеграционный тест `share/*` с `fs` и `s3`. + +## 4. Конфигурация, health, проводка, документация + +- [x] 4.1 Ввести `STORAGE_BACKEND=s3|fs`, `FS_ROOT`, `URL_SECRET` с условной валидацией; проверка — юнит-тесты `config.Load` для `s3`, `fs` и неполной конфигурации. +- [x] 4.2 Сделать метку хранилища в health динамической по выбранному бэкенду; проверка — тест `/api/health` для `s3` и `fs`. +- [x] 4.3 Обновить `.env.example` и `project.md`; проверка — документированный запуск с `STORAGE_BACKEND=fs` стартует и отдаёт файл. + +## 5. Интеграционные проверки + +- [x] 5.1 Прогнать полный поток (загрузка → превью → просмотр → скачивание → share) на `fs`; проверка — наблюдаемые результаты совпадают с `s3`. +- [x] 5.2 Убедиться, что дефолт `s3` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше. + +## 6. Доставка только через бэкенд (без presign) + +- [x] 6.1 Вынести подпись URL в общий `Signer` и использовать его в `FS` и `S3`; проверка — юнит-тесты подписи и `go build ./...` проходят. +- [x] 6.2 Сделать `ServeObject` обязательной частью `storage.Backend`; `app.serveObject` диспетчеризуется без type-assert; проверка — `go build ./...`. +- [x] 6.3 Реализовать `S3.ServeObject` через ranged `GetObject` + стриминг и HEAD через `HeadObject`; проверка — httptest-тесты 200/206/416/404. +- [x] 6.4 Удалить presign-путь (`PresignClient`/`PresignGetObject`), вернуть `S3.URL` как app-signed; проверка — код presign отсутствует, тесты отдачи проходят. +- [x] 6.5 Конфиг: переименовать `S3_PRESIGN_TTL` → `OBJECT_URL_TTL`, удалить `S3_PUBLIC_URL`; обновить `.env.example`, `project.md`, `deploy/nginx-sse.conf` (`proxy_buffering off` для `/api/objects/`); проверка — юнит-тесты `config.Load` и документированный запуск. +- [x] 6.6 Интеграция: полный поток и share на `s3` в proxy-режиме при недоступном извне хранилище; проверка — тест зелёный, наружу уходит URL бэкенда, не S3. diff --git a/project.md b/project.md index bf0bd8b..56f2baa 100644 --- a/project.md +++ b/project.md @@ -144,14 +144,19 @@ PG_PASSWORD=change_me PG_DATABASE=shared_album PG_SSLMODE=disable -# S3 (Garage) +# S3 (Garage) - внутреннее хранилище, клиенту не раскрывается S3_ENDPOINT=http://s3.localnet:3900 S3_REGION=garage S3_ACCESS_KEY=GKchange_me S3_SECRET_KEY=change_me S3_BUCKET=albums S3_USE_PATH_STYLE=true -S3_PUBLIC_URL=http://s3.localnet:3900/albums + +# Storage backend +STORAGE_BACKEND=s3 # s3 (default) | fs (local filesystem) +FS_ROOT=/var/lib/galo/objects # required when STORAGE_BACKEND=fs +URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET +OBJECT_URL_TTL=1h # lifetime of signed object URLs # Auth JWT_SECRET=change_me_long_random @@ -339,6 +344,11 @@ colors: { ## 10. Слой хранения и слой метаданных +**Слой хранения выбирается параметром `STORAGE_BACKEND`:** `s3` (S3-совместимое +хранилище, по умолчанию) или `fs` (локальная файловая система, `FS_ROOT`). Оба +бэкенда реализуют единый интерфейс `storage.Backend` и используют одни и те же +непрозрачные ключи, поэтому переключение не меняет формат данных. + **S3 (Garage) — непрозрачное объектное хранилище.** Ключи не содержат сущностей метаданных (альбома, фото). Каждый физический файл адресуется своим `object_id`: @@ -350,6 +360,11 @@ colors: { └── preview.webp ``` +При `STORAGE_BACKEND=fs` те же ключи отображаются в файлы под `FS_ROOT`. Отдача +идёт через сам сервис: `GET /api/objects/` с HMAC-подписью (`e`, `s`), +поддержкой Range и Content-Type. Один и тот же маршрут обслуживает авторизованных +пользователей и анонимные share-ссылки. + **Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime, размер, габариты, checksum, состояние `pending|ready|deleting`), таблица `photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`), @@ -358,13 +373,16 @@ colors: { Следствия: - **Копирование** между альбомами — только новые строки `photos`+`photo_objects`, - ссылающиеся на те же объекты; операции с S3 не выполняются. + ссылающиеся на те же объекты; операции с хранилищем не выполняются. - **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются. - **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям). -Доступ: **presigned URL** со сроком 1 час (бакет приватный). -Garage поддерживает presigned URL через SigV4 query-string signatures. +Доступ: **только через бэкенд** — подписанный URL со сроком 1 час на +`/api/objects/`, независимо от бэкенда хранилища. Для S3 (Garage) байты +стримятся из внутренней сети через бэкенд (ranged `GetObject`), presigned URL наружу +не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие +блобы — перенос выполняется вручную. ## 11. API-эндпоинты @@ -609,7 +627,8 @@ invites, photos, comments, likes, notifications. Применяются к pg.lo Реализуй загрузку фото в Garage с генерацией трёх превью (thumb 200px, preview 1200px, original) и извлечением EXIF. Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}. -Отдавай фото через presigned URL (срок 1 час). +Отдавай фото через бэкенд: подписанный URL `/api/objects/` (срок 1 час), +S3-байты стримятся из внутренней сети через бэкенд. Эндпоинты загрузки, списка с cursor-пагинацией, удаления. На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой, Lightbox со свайпами. -- 2.53.0 From 176a8d8cedfc3634a97f0589bb06178110a87db2 Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 10:08:31 +0300 Subject: [PATCH 2/5] feat(metadata): SQLite backend, storage reconciliation and local defaults - pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls - SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx) - separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite - cmd/reconcile compares object storage with the journal (report, then --delete) - storage.Backend.List and photos.Repository.AllStorageKeys - DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults (sqlite ./galo.db, fs ./data) - Makefile reconcile/reconcile-delete; docs and .env.example updated --- .env.example | 13 +- .gitignore | 5 + Makefile | 11 +- README.md | 25 + backend/cmd/reconcile/main.go | 162 ++++++ backend/cmd/server/main.go | 69 ++- backend/embed.go | 4 +- backend/go.mod | 9 +- backend/go.sum | 47 +- backend/internal/albums/repository.go | 18 + backend/internal/albums/service.go | 4 +- backend/internal/albums/sqlite_store.go | 142 +++++ backend/internal/app/app.go | 42 +- backend/internal/app/health_test.go | 14 +- backend/internal/app/objects_test.go | 4 +- backend/internal/app/share_flow_test.go | 3 +- backend/internal/auth/repository.go | 19 + backend/internal/auth/service.go | 4 +- backend/internal/auth/sqlite_store.go | 111 ++++ backend/internal/config/config.go | 28 +- backend/internal/config/config_test.go | 76 ++- backend/internal/db/migrate.go | 31 + backend/internal/db/migrate_sqlite_test.go | 37 ++ backend/internal/db/sqlite.go | 31 + backend/internal/db/sqlite_test.go | 45 ++ backend/internal/e2e/sqlite_test.go | 150 +++++ backend/internal/members/repository.go | 19 + backend/internal/members/service.go | 4 +- backend/internal/members/sqlite_store.go | 133 +++++ backend/internal/notifications/repository.go | 14 + backend/internal/notifications/service.go | 4 +- .../internal/notifications/sqlite_store.go | 88 +++ backend/internal/photos/objects.go | 18 + backend/internal/photos/repository.go | 52 ++ backend/internal/photos/service.go | 4 +- backend/internal/photos/sqlite_helpers.go | 30 + backend/internal/photos/sqlite_objects.go | 365 ++++++++++++ backend/internal/photos/sqlite_store.go | 470 +++++++++++++++ backend/internal/reconcile/reconcile.go | 74 +++ backend/internal/reconcile/reconcile_test.go | 41 ++ backend/internal/repo/contract/contract.go | 533 ++++++++++++++++++ .../internal/repo/contract/postgres_test.go | 78 +++ backend/internal/repo/contract/sqlite_test.go | 57 ++ backend/internal/sharelinks/repository.go | 15 + backend/internal/sharelinks/service.go | 4 +- backend/internal/sharelinks/sqlite_store.go | 90 +++ backend/internal/social/repository.go | 17 + backend/internal/social/service.go | 4 +- backend/internal/social/sqlite_store.go | 101 ++++ backend/internal/sqliteutil/sqliteutil.go | 50 ++ backend/internal/storage/fs.go | 32 ++ backend/internal/storage/fs_test.go | 30 + backend/internal/storage/s3_test.go | 27 + backend/internal/storage/storage.go | 40 ++ backend/internal/store/store.go | 55 ++ backend/internal/store/store_test.go | 55 ++ .../migrations/sqlite/000001_init.down.sql | 15 + backend/migrations/sqlite/000001_init.up.sql | 159 ++++++ .../sqlite-metadata-backend/.openspec.yaml | 2 + .../changes/sqlite-metadata-backend/design.md | 120 ++++ .../sqlite-metadata-backend/proposal.md | 37 ++ .../specs/metadata-backends/spec.md | 76 +++ .../changes/sqlite-metadata-backend/tasks.md | 32 ++ .../.openspec.yaml | 2 + .../storage-orphan-reconciliation/design.md | 82 +++ .../storage-orphan-reconciliation/proposal.md | 36 ++ .../specs/storage-reconciliation/spec.md | 65 +++ .../storage-orphan-reconciliation/tasks.md | 21 + project.md | 14 +- 69 files changed, 4174 insertions(+), 95 deletions(-) create mode 100644 backend/cmd/reconcile/main.go create mode 100644 backend/internal/albums/repository.go create mode 100644 backend/internal/albums/sqlite_store.go create mode 100644 backend/internal/auth/repository.go create mode 100644 backend/internal/auth/sqlite_store.go create mode 100644 backend/internal/db/migrate_sqlite_test.go create mode 100644 backend/internal/db/sqlite.go create mode 100644 backend/internal/db/sqlite_test.go create mode 100644 backend/internal/e2e/sqlite_test.go create mode 100644 backend/internal/members/repository.go create mode 100644 backend/internal/members/sqlite_store.go create mode 100644 backend/internal/notifications/repository.go create mode 100644 backend/internal/notifications/sqlite_store.go create mode 100644 backend/internal/photos/repository.go create mode 100644 backend/internal/photos/sqlite_helpers.go create mode 100644 backend/internal/photos/sqlite_objects.go create mode 100644 backend/internal/photos/sqlite_store.go create mode 100644 backend/internal/reconcile/reconcile.go create mode 100644 backend/internal/reconcile/reconcile_test.go create mode 100644 backend/internal/repo/contract/contract.go create mode 100644 backend/internal/repo/contract/postgres_test.go create mode 100644 backend/internal/repo/contract/sqlite_test.go create mode 100644 backend/internal/sharelinks/repository.go create mode 100644 backend/internal/sharelinks/sqlite_store.go create mode 100644 backend/internal/social/repository.go create mode 100644 backend/internal/social/sqlite_store.go create mode 100644 backend/internal/sqliteutil/sqliteutil.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/internal/store/store_test.go create mode 100644 backend/migrations/sqlite/000001_init.down.sql create mode 100644 backend/migrations/sqlite/000001_init.up.sql create mode 100644 openspec/changes/sqlite-metadata-backend/.openspec.yaml create mode 100644 openspec/changes/sqlite-metadata-backend/design.md create mode 100644 openspec/changes/sqlite-metadata-backend/proposal.md create mode 100644 openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md create mode 100644 openspec/changes/sqlite-metadata-backend/tasks.md create mode 100644 openspec/changes/storage-orphan-reconciliation/.openspec.yaml create mode 100644 openspec/changes/storage-orphan-reconciliation/design.md create mode 100644 openspec/changes/storage-orphan-reconciliation/proposal.md create mode 100644 openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md create mode 100644 openspec/changes/storage-orphan-reconciliation/tasks.md 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 -- 2.53.0 From cb272398ceeee289b3942611da190bc191da3464 Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 10:18:31 +0300 Subject: [PATCH 3/5] feat(auth): store the token signing secret in the metadata layer - app_settings table (PostgreSQL migration 000010, SQLite 000002) - internal/settings repository (Get/Set/Ensure) wired into store.Repos - secret generated with crypto/rand on first start and persisted; reused on restart - drop required JWT_SECRET from env/config; app.New takes a TokenManager and storage.Open takes the resolved secret (URL_SECRET still overrides) - docs updated --- .env.example | 6 +- README.md | 6 +- backend/cmd/backfill-exif/main.go | 15 +++- backend/cmd/reconcile/main.go | 9 ++- backend/cmd/server/main.go | 13 +++- backend/internal/app/app.go | 3 +- backend/internal/app/objects_test.go | 4 +- backend/internal/app/share_flow_test.go | 3 +- backend/internal/config/config.go | 6 -- backend/internal/e2e/sqlite_test.go | 48 ++++++++++++ backend/internal/repo/contract/contract.go | 27 +++++++ .../internal/repo/contract/postgres_test.go | 4 +- backend/internal/repo/contract/sqlite_test.go | 4 +- backend/internal/settings/generate.go | 15 ++++ backend/internal/settings/settings.go | 20 +++++ backend/internal/settings/sqlite_store.go | 63 +++++++++++++++ backend/internal/settings/store.go | 62 +++++++++++++++ backend/internal/storage/storage.go | 10 ++- backend/internal/store/store.go | 4 + .../migrations/000010_app_settings.down.sql | 1 + backend/migrations/000010_app_settings.up.sql | 6 ++ .../sqlite/000002_app_settings.down.sql | 1 + .../sqlite/000002_app_settings.up.sql | 6 ++ .../.openspec.yaml | 2 + .../metadata-managed-jwt-secret/design.md | 76 +++++++++++++++++++ .../metadata-managed-jwt-secret/proposal.md | 36 +++++++++ .../specs/app-secrets/spec.md | 48 ++++++++++++ .../metadata-managed-jwt-secret/tasks.md | 15 ++++ project.md | 6 +- 29 files changed, 489 insertions(+), 30 deletions(-) create mode 100644 backend/internal/settings/generate.go create mode 100644 backend/internal/settings/settings.go create mode 100644 backend/internal/settings/sqlite_store.go create mode 100644 backend/internal/settings/store.go create mode 100644 backend/migrations/000010_app_settings.down.sql create mode 100644 backend/migrations/000010_app_settings.up.sql create mode 100644 backend/migrations/sqlite/000002_app_settings.down.sql create mode 100644 backend/migrations/sqlite/000002_app_settings.up.sql create mode 100644 openspec/changes/metadata-managed-jwt-secret/.openspec.yaml create mode 100644 openspec/changes/metadata-managed-jwt-secret/design.md create mode 100644 openspec/changes/metadata-managed-jwt-secret/proposal.md create mode 100644 openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md create mode 100644 openspec/changes/metadata-managed-jwt-secret/tasks.md diff --git a/.env.example b/.env.example index 206ac7a..8249555 100644 --- a/.env.example +++ b/.env.example @@ -24,13 +24,13 @@ S3_USE_PATH_STYLE=true STORAGE_BACKEND=fs # Root directory for object blobs (STORAGE_BACKEND=fs) FS_ROOT=./data -# Signs object URLs; falls back to JWT_SECRET when empty +# Signs object URLs; falls back to the metadata-layer secret when empty URL_SECRET= # Lifetime of signed object URLs OBJECT_URL_TTL=1h -# Auth -JWT_SECRET=change_me_long_random +# Auth. The token signing secret is generated on first start and stored in the +# metadata layer (app_settings); it is not configured here. JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=720h diff --git a/README.md b/README.md index 418e17d..b28ad37 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,10 @@ cp .env.example .env ``` Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион -`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, -`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. +`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`, +`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся +в окружении: он генерируется при первом старте и хранится в слое метаданных +(`app_settings`). Слои данных выбираются параметрами (по умолчанию — локальный режим): diff --git a/backend/cmd/backfill-exif/main.go b/backend/cmd/backfill-exif/main.go index aa525f4..3a6137a 100644 --- a/backend/cmd/backfill-exif/main.go +++ b/backend/cmd/backfill-exif/main.go @@ -9,8 +9,9 @@ import ( "shared-album/internal/config" "shared-album/internal/db" - "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/storage" + "shared-album/internal/store" ) func main() { @@ -35,14 +36,20 @@ func main() { } defer pool.Close() - store, err := storage.Open(ctx, cfg) + repos := store.NewPostgres(pool) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) } - photoStore := photos.NewStore(pool) - updated, err := photoStore.BackfillEXIF(ctx, store.Get) + updated, err := repos.Photos.BackfillEXIF(ctx, blob.Get) if err != nil { slog.Error("backfill failed", "error", err) os.Exit(1) diff --git a/backend/cmd/reconcile/main.go b/backend/cmd/reconcile/main.go index 7921a2b..04897ab 100644 --- a/backend/cmd/reconcile/main.go +++ b/backend/cmd/reconcile/main.go @@ -18,6 +18,7 @@ import ( "shared-album/internal/config" "shared-album/internal/db" "shared-album/internal/reconcile" + "shared-album/internal/settings" "shared-album/internal/storage" "shared-album/internal/store" ) @@ -50,7 +51,13 @@ func main() { } defer closer() - blob, err := storage.Open(ctx, cfg) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f580283..d4fd657 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -15,8 +15,10 @@ import ( assets "shared-album" "shared-album/internal/app" + "shared-album/internal/auth" "shared-album/internal/config" "shared-album/internal/db" + "shared-album/internal/settings" "shared-album/internal/sse" "shared-album/internal/storage" "shared-album/internal/store" @@ -84,7 +86,14 @@ func main() { defer closer() slog.Info("migrations applied", "db_backend", cfg.Database.Backend) - blob, err := storage.Open(ctx, cfg) + secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + slog.Error("secret init failed", "error", err) + os.Exit(1) + } + tm := auth.NewTokenManager(secret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL) + + blob, err := storage.Open(ctx, cfg, secret) if err != nil { slog.Error("storage init failed", "error", err) os.Exit(1) @@ -96,7 +105,7 @@ func main() { } hub := sse.NewHub(cfg.SSE.ClientBuffer) - server := app.New(cfg, repos, ping, blob, hub) + server := app.New(cfg, repos, ping, tm, blob, hub) go server.StartWorker(ctx) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index ffe07b6..b775fc1 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -50,8 +50,7 @@ type Server struct { // 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) +func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server { authSvc := auth.NewService(repos.Auth, tm) notifSvc := notifications.NewService(repos.Notifications, hub) diff --git a/backend/internal/app/objects_test.go b/backend/internal/app/objects_test.go index e107b24..fb94e8f 100644 --- a/backend/internal/app/objects_test.go +++ b/backend/internal/app/objects_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" + "shared-album/internal/auth" "shared-album/internal/config" "shared-album/internal/sse" "shared-album/internal/storage" @@ -30,7 +31,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { t.Fatalf("new fs: %v", err) } noop := func(context.Context) error { return nil } - srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1)) + tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, store.Repos{}, noop, tm, fs, sse.NewHub(1)) return fs, srv.Router() } diff --git a/backend/internal/app/share_flow_test.go b/backend/internal/app/share_flow_test.go index 41bdb3f..8548ad9 100644 --- a/backend/internal/app/share_flow_test.go +++ b/backend/internal/app/share_flow_test.go @@ -71,7 +71,8 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing. gin.SetMode(gin.TestMode) hub := sse.NewHub(1) - srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub) + tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour) + srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub) router := srv.Router() authStore := auth.NewStore(pool) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 20f62c0..ef51c83 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -31,7 +31,6 @@ type S3 struct { } type Auth struct { - JWTSecret string AccessTTL time.Duration RefreshTTL time.Duration } @@ -154,7 +153,6 @@ func Load() (*Config, error) { SQLitePath: getEnv("SQLITE_PATH", "./galo.db"), }, Auth: Auth{ - JWTSecret: getEnv("JWT_SECRET", ""), AccessTTL: accessTTL, RefreshTTL: refreshTTL, }, @@ -173,10 +171,6 @@ func Load() (*Config, error) { }, } - if cfg.Auth.JWTSecret == "" { - return nil, fmt.Errorf("JWT_SECRET is required") - } - switch cfg.Database.Backend { case "postgres": if cfg.Postgres.Password == "" { diff --git a/backend/internal/e2e/sqlite_test.go b/backend/internal/e2e/sqlite_test.go index a040cbe..29e9046 100644 --- a/backend/internal/e2e/sqlite_test.go +++ b/backend/internal/e2e/sqlite_test.go @@ -17,6 +17,7 @@ import ( "shared-album/internal/auth" "shared-album/internal/db" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sse" "shared-album/internal/storage" "shared-album/internal/store" @@ -137,6 +138,53 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) { }) } +func TestJWTSecretPersistsAcrossRestart(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "galo.db") + migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite") + if err != nil { + t.Fatalf("migrations fs: %v", err) + } + + open := func() (*sql.DB, store.Repos) { + t.Helper() + if err := db.MigrateSQLite(path, migFS); err != nil { + t.Fatalf("migrate: %v", err) + } + database, err := db.OpenSQLite(path) + if err != nil { + t.Fatalf("open: %v", err) + } + return database, store.NewSQLite(database) + } + + db1, repos1 := open() + secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil || secret1 == "" { + t.Fatalf("ensure secret: %v %q", err, secret1) + } + tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour) + token, err := tm1.GenerateAccess("user-1", "u@test.local") + if err != nil { + t.Fatalf("generate: %v", err) + } + _ = db1.Close() + + db2, repos2 := open() + defer db2.Close() + secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret) + if err != nil { + t.Fatalf("ensure after restart: %v", err) + } + if secret2 != secret1 { + t.Fatalf("secret changed across restart") + } + tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour) + if _, err := tm2.ParseAccess(token); err != nil { + t.Fatalf("token issued before restart not valid after: %v", err) + } +} + func waitFor(t *testing.T, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(10 * time.Second) diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 8261518..c1fa1c5 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -15,6 +15,7 @@ import ( "shared-album/internal/models" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -29,6 +30,7 @@ type Repos struct { Social social.Repository Notifications notifications.Repository ShareLinks sharelinks.Repository + Settings settings.Repository Reset func(ctx context.Context) error } @@ -47,6 +49,7 @@ func Run(t *testing.T, r Repos) { {"Social", testSocial}, {"Photos", testPhotos}, {"Objects", testObjects}, + {"Settings", testSettings}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -523,6 +526,30 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { } } +func testSettings(t *testing.T, ctx context.Context, r Repos) { + v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil }) + if err != nil || v != "generated-1" { + t.Fatalf("ensure first: %v %q", err, v) + } + v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil }) + if err != nil || v2 != "generated-1" { + t.Fatalf("ensure not idempotent: %v %q", err, v2) + } + got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey) + if err != nil || !ok || got != "generated-1" { + t.Fatalf("get: %v ok=%v %q", err, ok, got) + } + if _, ok, _ := r.Settings.Get(ctx, "missing"); ok { + t.Fatalf("missing key reported present") + } + if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil { + t.Fatalf("set: %v", err) + } + if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" { + t.Fatalf("set not applied: %q", got) + } +} + func contains(list []string, v string) bool { for _, x := range list { if x == v { diff --git a/backend/internal/repo/contract/postgres_test.go b/backend/internal/repo/contract/postgres_test.go index 8f0f61e..c8df098 100644 --- a/backend/internal/repo/contract/postgres_test.go +++ b/backend/internal/repo/contract/postgres_test.go @@ -17,6 +17,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -70,8 +71,9 @@ func TestPostgresContract(t *testing.T) { Social: social.NewStore(pool), Notifications: notifications.NewStore(pool), ShareLinks: sharelinks.NewStore(pool), + Settings: settings.NewStore(pool), Reset: func(ctx context.Context) error { - _, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`) + _, err := pool.Exec(ctx, `TRUNCATE users, objects, app_settings CASCADE`) return err }, }) diff --git a/backend/internal/repo/contract/sqlite_test.go b/backend/internal/repo/contract/sqlite_test.go index 1d65985..0b52489 100644 --- a/backend/internal/repo/contract/sqlite_test.go +++ b/backend/internal/repo/contract/sqlite_test.go @@ -13,6 +13,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -40,11 +41,12 @@ func TestSQLiteContract(t *testing.T) { Social: social.NewSQLiteStore(database), Notifications: notifications.NewSQLiteStore(database), ShareLinks: sharelinks.NewSQLiteStore(database), + Settings: settings.NewSQLiteStore(database), Reset: func(ctx context.Context) error { tables := []string{ "variant_jobs", "photo_objects", "objects", "photo_tags", "tags", "refresh_tokens", "share_links", "notifications", "likes", "comments", - "invites", "album_members", "photos", "albums", "users", + "invites", "album_members", "photos", "albums", "users", "app_settings", } for _, tbl := range tables { if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil { diff --git a/backend/internal/settings/generate.go b/backend/internal/settings/generate.go new file mode 100644 index 0000000..1324a25 --- /dev/null +++ b/backend/internal/settings/generate.go @@ -0,0 +1,15 @@ +package settings + +import ( + "crypto/rand" + "encoding/base64" +) + +// GenerateSecret returns a new random secret suitable for HMAC signing. +func GenerateSecret() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} diff --git a/backend/internal/settings/settings.go b/backend/internal/settings/settings.go new file mode 100644 index 0000000..9cb2593 --- /dev/null +++ b/backend/internal/settings/settings.go @@ -0,0 +1,20 @@ +// Package settings persists application-level key/value settings in the +// metadata layer. The main use is the token signing secret, generated on first +// use and reused afterwards. +package settings + +import "context" + +// JWTSecretKey is the settings key holding the token signing secret. +const JWTSecretKey = "jwt_secret" + +// Repository reads and writes application settings. +type Repository interface { + // Get returns the value for key and whether it exists. + Get(ctx context.Context, key string) (string, bool, error) + // Set stores or replaces the value for key. + Set(ctx context.Context, key, value string) error + // Ensure returns the existing value for key, or generates and stores one + // using generate when absent. Concurrent first use yields one value. + Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) +} diff --git a/backend/internal/settings/sqlite_store.go b/backend/internal/settings/sqlite_store.go new file mode 100644 index 0000000..61970d6 --- /dev/null +++ b/backend/internal/settings/sqlite_store.go @@ -0,0 +1,63 @@ +package settings + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "shared-album/internal/sqliteutil" +) + +// SQLiteStore is the SQLite implementation of Repository. +type SQLiteStore struct { + db *sql.DB +} + +func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} } + +func (s *SQLiteStore) Get(ctx context.Context, key string) (string, bool, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + return v, true, nil +} + +func (s *SQLiteStore) Set(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = $3`, + key, value, sqliteutil.FormatTime(time.Now())) + return err +} + +func (s *SQLiteStore) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) { + if v, ok, err := s.Get(ctx, key); err != nil { + return "", err + } else if ok { + return v, nil + } + v, err := generate() + if err != nil { + return "", err + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + key, v); err != nil { + return "", err + } + got, ok, err := s.Get(ctx, key) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("settings: %q missing after insert", key) + } + return got, nil +} diff --git a/backend/internal/settings/store.go b/backend/internal/settings/store.go new file mode 100644 index 0000000..d9c6250 --- /dev/null +++ b/backend/internal/settings/store.go @@ -0,0 +1,62 @@ +package settings + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Store is the PostgreSQL implementation of Repository. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) Get(ctx context.Context, key string) (string, bool, error) { + var v string + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + return v, true, nil +} + +func (s *Store) Set(ctx context.Context, key, value string) error { + _, err := s.pool.Exec(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + key, value) + return err +} + +func (s *Store) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) { + if v, ok, err := s.Get(ctx, key); err != nil { + return "", err + } else if ok { + return v, nil + } + v, err := generate() + if err != nil { + return "", err + } + if _, err := s.pool.Exec(ctx, + `INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + key, v); err != nil { + return "", err + } + got, ok, err := s.Get(ctx, key) + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("settings: %q missing after insert", key) + } + return got, nil +} diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index ec1f7fb..7303f7c 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -98,11 +98,15 @@ func (c *S3) Bucket() string { return c.bucket } // Open builds the storage backend selected by configuration: S3-compatible by // default, or the local file system when STORAGE_BACKEND=fs. Every backend -// delivers blobs through the app using a shared Signer. -func Open(ctx context.Context, cfg *config.Config) (Backend, error) { +// delivers blobs through the app using a shared Signer. jwtSecret is the +// application secret used to sign object URLs when URL_SECRET is not set. +func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, error) { secret := cfg.Storage.URLSecret if secret == "" { - secret = cfg.Auth.JWTSecret + secret = jwtSecret + } + if secret == "" { + return nil, errors.New("object URL secret is not available") } signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL) if err != nil { diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 085b4e1..54eda95 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -13,6 +13,7 @@ import ( "shared-album/internal/members" "shared-album/internal/notifications" "shared-album/internal/photos" + "shared-album/internal/settings" "shared-album/internal/sharelinks" "shared-album/internal/social" ) @@ -26,6 +27,7 @@ type Repos struct { Social social.Repository Notifications notifications.Repository ShareLinks sharelinks.Repository + Settings settings.Repository } // NewPostgres builds the repositories backed by PostgreSQL. @@ -38,6 +40,7 @@ func NewPostgres(pool *pgxpool.Pool) Repos { Social: social.NewStore(pool), Notifications: notifications.NewStore(pool), ShareLinks: sharelinks.NewStore(pool), + Settings: settings.NewStore(pool), } } @@ -51,5 +54,6 @@ func NewSQLite(db *sql.DB) Repos { Social: social.NewSQLiteStore(db), Notifications: notifications.NewSQLiteStore(db), ShareLinks: sharelinks.NewSQLiteStore(db), + Settings: settings.NewSQLiteStore(db), } } diff --git a/backend/migrations/000010_app_settings.down.sql b/backend/migrations/000010_app_settings.down.sql new file mode 100644 index 0000000..ca6d341 --- /dev/null +++ b/backend/migrations/000010_app_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS app_settings; diff --git a/backend/migrations/000010_app_settings.up.sql b/backend/migrations/000010_app_settings.up.sql new file mode 100644 index 0000000..54d93a8 --- /dev/null +++ b/backend/migrations/000010_app_settings.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + key text PRIMARY KEY, + value text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); diff --git a/backend/migrations/sqlite/000002_app_settings.down.sql b/backend/migrations/sqlite/000002_app_settings.down.sql new file mode 100644 index 0000000..ca6d341 --- /dev/null +++ b/backend/migrations/sqlite/000002_app_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS app_settings; diff --git a/backend/migrations/sqlite/000002_app_settings.up.sql b/backend/migrations/sqlite/000002_app_settings.up.sql new file mode 100644 index 0000000..2439715 --- /dev/null +++ b/backend/migrations/sqlite/000002_app_settings.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); diff --git a/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml b/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/metadata-managed-jwt-secret/design.md b/openspec/changes/metadata-managed-jwt-secret/design.md new file mode 100644 index 0000000..0eb4c6d --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/design.md @@ -0,0 +1,76 @@ +# Design + +## Context + +См. `proposal.md` — Why. Текущее состояние: + +- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager` + (HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`. +- Refresh-токены не подписываются секретом (случайные, хранятся хэшем). +- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и + фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`). +- Порядок старта в `cmd/server`: миграции → метаданные → storage → app. + +## Goals / Non-Goals + +**Goals:** +- Хранить секрет приложения в слое метаданных; генерировать при первом использовании. +- Убрать обязательный `JWT_SECRET` из окружения. +- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте. + +**Non-Goals:** +- Шифрование секрета в БД (БД доверенная). +- UI/команда ротации секрета. +- Миграция существующих env-секретов в БД. +- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента. + +## Decisions + +### D1. Таблица `app_settings` +`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих +бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`, +SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД). + +### D2. Репозиторий `internal/settings` +Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`. +Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать, +`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке). +Добавляется в `store.Repos`. + +### D3. Генерация секрета +`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в +TokenManager и Signer передаётся как есть. + +### D4. Порядок старта и проводка +`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)` +→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)` +(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`. +`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager` +(или секрет). + +### D5. Конфиг +Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL` +остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром +вместо чтения из конфига. + +## Risks / Trade-offs + +- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но + refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access; + в худшем случае — перелогин. Отмечаем в доках. +- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный + `SELECT` (D2). +- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок. +- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД; + явный `URL_SECRET` снимает зависимость. + +## Migration Plan + +1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте. +2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`. +3. Обновить доки и `.env.example`. +4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются. + +## Open Questions + +- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов). diff --git a/openspec/changes/metadata-managed-jwt-secret/proposal.md b/openspec/changes/metadata-managed-jwt-secret/proposal.md new file mode 100644 index 0000000..da06f6b --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на +всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный +секрет из env и даёт стабильное значение на инсталляцию. + +## What Changes + +- Убрать обязательный `JWT_SECRET` из конфига и окружения. +- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и + SQLite) и репозиторий чтения/создания секрета. +- При старте после открытия метаданных читать секрет или генерировать его + (`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи + URL объектов. +- `URL_SECRET` остаётся необязательным env-оверрайдом. +- Обновить `.env.example`, `project.md`, `README.md`. + +## Capabilities + +### New Capabilities +- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании. + +### Modified Capabilities + + +## Impact + +- `backend/internal/config` (убрать `JWTSecret` и его обязательность). +- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет → + storage/app). +- `backend/internal/storage` (fallback-секрет для подписи URL). +- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite. +- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`. +- Не затрагивает формат данных клиента. diff --git a/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md b/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md new file mode 100644 index 0000000..505a5b5 --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md @@ -0,0 +1,48 @@ +# Spec Delta + +## Purpose + +Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный +секрет на инсталляцию без обязательной настройки окружения. + +## ADDED Requirements + +### Requirement: Хранение секрета приложения +Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один +и тот же секрет при последующих запусках. + +#### Scenario: Первый запуск +- **WHEN** секрет ещё не сохранён +- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных + +#### Scenario: Повторный запуск +- **WHEN** секрет уже сохранён +- **THEN** система использует сохранённый секрет и не генерирует новый + +### Requirement: Отсутствие ручного секрета +Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его +наличия при старте. + +#### Scenario: Секрет не задан в окружении +- **WHEN** переменная секрета не задана +- **THEN** система запускается, используя секрет из слоя метаданных + +### Requirement: Конкурентный первый запуск +При одновременном первом запуске нескольких инстансов на общей базе метаданных система +SHALL обеспечивать единый сохранённый секрет для всех инстансов. + +#### Scenario: Гонка первого запуска +- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета +- **THEN** сохраняется ровно один секрет, и оба инстанса используют его + +### Requirement: Использование секрета для подписи URL +При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя +метаданных. + +#### Scenario: URL_SECRET не задан +- **WHEN** отдельный секрет подписи URL не задан +- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных + +#### Scenario: URL_SECRET задан +- **WHEN** отдельный секрет подписи URL задан +- **THEN** он используется независимо от секрета приложения diff --git a/openspec/changes/metadata-managed-jwt-secret/tasks.md b/openspec/changes/metadata-managed-jwt-secret/tasks.md new file mode 100644 index 0000000..caefbed --- /dev/null +++ b/openspec/changes/metadata-managed-jwt-secret/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +## 1. Репозиторий секретов и миграции + +- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность. +- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение. + +## 2. Проводка и конфиг + +- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`. +- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после). + +## 3. Документация + +- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует. diff --git a/project.md b/project.md index 3f31e54..dd993d0 100644 --- a/project.md +++ b/project.md @@ -159,11 +159,11 @@ S3_USE_PATH_STYLE=true # Storage backend 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 +URL_SECRET=change_me # signs object URLs; falls back to the metadata-layer secret OBJECT_URL_TTL=1h # lifetime of signed object URLs -# Auth -JWT_SECRET=change_me_long_random +# Auth. Token signing secret is generated on first start and stored in the +# metadata layer (app_settings) — not configured via env. JWT_ACCESS_TTL=15m JWT_REFRESH_TTL=720h -- 2.53.0 From 8638d1bbfc1fa7980b6924bded708fe2c29678a3 Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 10:19:44 +0300 Subject: [PATCH 4/5] chore(openspec): archive completed changes and sync main specs - archive local-fs-storage-backend, sqlite-metadata-backend, storage-orphan-reconciliation, metadata-managed-jwt-secret, default-local-backends - sync specs: storage-backends, metadata-backends, storage-reconciliation, app-secrets; update photos/share-links - defaults reflected in specs: SQLite and local FS --- .../.openspec.yaml | 0 .../proposal.md | 30 +++++ .../specs/metadata-backends/spec.md | 23 ++++ .../specs/storage-backends/spec.md | 23 ++++ .../tasks.md | 6 + .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/photos/spec.md | 0 .../specs/share-links/spec.md | 0 .../specs/storage-backends/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/app-secrets/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/metadata-backends/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 2 + .../design.md | 0 .../proposal.md | 0 .../specs/storage-reconciliation/spec.md | 0 .../tasks.md | 0 openspec/specs/app-secrets/spec.md | 47 ++++++++ openspec/specs/metadata-backends/spec.md | 79 +++++++++++++ openspec/specs/photos/spec.md | 18 +-- openspec/specs/share-links/spec.md | 2 +- openspec/specs/storage-backends/spec.md | 111 ++++++++++++++++++ openspec/specs/storage-reconciliation/spec.md | 64 ++++++++++ 33 files changed, 392 insertions(+), 13 deletions(-) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-default-local-backends}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-10-11-default-local-backends/proposal.md create mode 100644 openspec/changes/archive/2026-10-11-default-local-backends/specs/metadata-backends/spec.md create mode 100644 openspec/changes/archive/2026-10-11-default-local-backends/specs/storage-backends/spec.md create mode 100644 openspec/changes/archive/2026-10-11-default-local-backends/tasks.md rename openspec/changes/{metadata-managed-jwt-secret => archive/2026-10-11-local-fs-storage-backend}/.openspec.yaml (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/design.md (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/proposal.md (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/specs/photos/spec.md (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/specs/share-links/spec.md (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/specs/storage-backends/spec.md (100%) rename openspec/changes/{local-fs-storage-backend => archive/2026-10-11-local-fs-storage-backend}/tasks.md (100%) rename openspec/changes/{sqlite-metadata-backend => archive/2026-10-11-metadata-managed-jwt-secret}/.openspec.yaml (100%) rename openspec/changes/{metadata-managed-jwt-secret => archive/2026-10-11-metadata-managed-jwt-secret}/design.md (100%) rename openspec/changes/{metadata-managed-jwt-secret => archive/2026-10-11-metadata-managed-jwt-secret}/proposal.md (100%) rename openspec/changes/{metadata-managed-jwt-secret => archive/2026-10-11-metadata-managed-jwt-secret}/specs/app-secrets/spec.md (100%) rename openspec/changes/{metadata-managed-jwt-secret => archive/2026-10-11-metadata-managed-jwt-secret}/tasks.md (100%) rename openspec/changes/{storage-orphan-reconciliation => archive/2026-10-11-sqlite-metadata-backend}/.openspec.yaml (100%) rename openspec/changes/{sqlite-metadata-backend => archive/2026-10-11-sqlite-metadata-backend}/design.md (100%) rename openspec/changes/{sqlite-metadata-backend => archive/2026-10-11-sqlite-metadata-backend}/proposal.md (100%) rename openspec/changes/{sqlite-metadata-backend => archive/2026-10-11-sqlite-metadata-backend}/specs/metadata-backends/spec.md (100%) rename openspec/changes/{sqlite-metadata-backend => archive/2026-10-11-sqlite-metadata-backend}/tasks.md (100%) create mode 100644 openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/.openspec.yaml rename openspec/changes/{storage-orphan-reconciliation => archive/2026-10-11-storage-orphan-reconciliation}/design.md (100%) rename openspec/changes/{storage-orphan-reconciliation => archive/2026-10-11-storage-orphan-reconciliation}/proposal.md (100%) rename openspec/changes/{storage-orphan-reconciliation => archive/2026-10-11-storage-orphan-reconciliation}/specs/storage-reconciliation/spec.md (100%) rename openspec/changes/{storage-orphan-reconciliation => archive/2026-10-11-storage-orphan-reconciliation}/tasks.md (100%) create mode 100644 openspec/specs/app-secrets/spec.md create mode 100644 openspec/specs/metadata-backends/spec.md create mode 100644 openspec/specs/storage-backends/spec.md create mode 100644 openspec/specs/storage-reconciliation/spec.md diff --git a/openspec/changes/local-fs-storage-backend/.openspec.yaml b/openspec/changes/archive/2026-10-11-default-local-backends/.openspec.yaml similarity index 100% rename from openspec/changes/local-fs-storage-backend/.openspec.yaml rename to openspec/changes/archive/2026-10-11-default-local-backends/.openspec.yaml diff --git a/openspec/changes/archive/2026-10-11-default-local-backends/proposal.md b/openspec/changes/archive/2026-10-11-default-local-backends/proposal.md new file mode 100644 index 0000000..f8a6815 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-default-local-backends/proposal.md @@ -0,0 +1,30 @@ +# Proposal + +## Why + +Слои данных были по умолчанию внешними (`postgres` и `s3`), поэтому самодостаточный +локальный запуск без внешних сервисов требовал явной настройки. Нужно сделать локальные +бэкенды значением по умолчанию. + +## What Changes + +- Сменить значения по умолчанию: `DB_BACKEND=sqlite` (`SQLITE_PATH=./galo.db`) и + `STORAGE_BACKEND=fs` (`FS_ROOT=./data`). +- Обновить `.env.example`, `project.md`, `README.md`. +- Поведение при явном выборе `postgres`/`s3` не меняется. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `metadata-backends`: значением по умолчанию становится встроенная SQLite (ранее PostgreSQL). +- `storage-backends`: значением по умолчанию становится локальная ФС (ранее S3-совместимая). + +## Impact + +- `backend/internal/config` (значения по умолчанию) и тесты. +- `.env.example`, `project.md`, `README.md`. +- **BREAKING (поведение):** развёртывания без явных `DB_BACKEND`/`STORAGE_BACKEND` + переключатся на локальные бэкенды; для прежнего режима задайте `postgres`/`s3` явно. diff --git a/openspec/changes/archive/2026-10-11-default-local-backends/specs/metadata-backends/spec.md b/openspec/changes/archive/2026-10-11-default-local-backends/specs/metadata-backends/spec.md new file mode 100644 index 0000000..2534818 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-default-local-backends/specs/metadata-backends/spec.md @@ -0,0 +1,23 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Выбор бэкенда метаданных +Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации, +при этом встроенная SQLite SHALL быть значением по умолчанию. + +#### Scenario: Выбор по умолчанию +- **WHEN** бэкенд метаданных не задан в конфигурации +- **THEN** система использует встроенную SQLite с путём по умолчанию + +#### Scenario: Выбор PostgreSQL +- **WHEN** в конфигурации задан бэкенд PostgreSQL +- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL + +#### Scenario: Выбор встроенной SQLite +- **WHEN** в конфигурации задан бэкенд SQLite +- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы + +#### Scenario: Неполная конфигурация +- **WHEN** конфигурация выбранного бэкенда неполна +- **THEN** система отказывается стартовать с явной ошибкой конфигурации diff --git a/openspec/changes/archive/2026-10-11-default-local-backends/specs/storage-backends/spec.md b/openspec/changes/archive/2026-10-11-default-local-backends/specs/storage-backends/spec.md new file mode 100644 index 0000000..c4af319 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-default-local-backends/specs/storage-backends/spec.md @@ -0,0 +1,23 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Выбор бэкенда хранилища +Система SHALL поддерживать выбор бэкенда объектного хранилища параметром конфигурации, +при этом локальная файловая система SHALL быть значением по умолчанию. + +#### Scenario: Выбор по умолчанию +- **WHEN** бэкенд хранилища не задан в конфигурации +- **THEN** система хранит и отдаёт блобы из локальной файловой системы с корнем по умолчанию + +#### Scenario: Выбор S3-совместимого бэкенда +- **WHEN** в конфигурации задан S3-совместимый бэкенд +- **THEN** система хранит и отдаёт блобы через S3-совместимое хранилище + +#### Scenario: Выбор локальной ФС +- **WHEN** в конфигурации задан бэкенд локальной ФС +- **THEN** система хранит и отдаёт блобы из каталога локальной файловой системы + +#### Scenario: Неполная конфигурация +- **WHEN** конфигурация выбранного бэкенда неполна +- **THEN** система отказывается стартовать с явной ошибкой конфигурации diff --git a/openspec/changes/archive/2026-10-11-default-local-backends/tasks.md b/openspec/changes/archive/2026-10-11-default-local-backends/tasks.md new file mode 100644 index 0000000..eeb6ffb --- /dev/null +++ b/openspec/changes/archive/2026-10-11-default-local-backends/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +## 1. Дефолты и документация + +- [x] 1.1 Сменить значения по умолчанию в `config` (`DB_BACKEND=sqlite`, `SQLITE_PATH=./galo.db`, `STORAGE_BACKEND=fs`, `FS_ROOT=./data`) и обновить тесты конфига; проверка — `go test ./internal/config` зелёный. +- [x] 1.2 Обновить `.env.example`, `project.md`, `README.md`; проверка — запуск без переменных окружения стартует на SQLite+ФС и health показывает `db=ok, fs=ok`. diff --git a/openspec/changes/metadata-managed-jwt-secret/.openspec.yaml b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/.openspec.yaml similarity index 100% rename from openspec/changes/metadata-managed-jwt-secret/.openspec.yaml rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/.openspec.yaml diff --git a/openspec/changes/local-fs-storage-backend/design.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/design.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/design.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/design.md diff --git a/openspec/changes/local-fs-storage-backend/proposal.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/proposal.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/proposal.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/proposal.md diff --git a/openspec/changes/local-fs-storage-backend/specs/photos/spec.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/photos/spec.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/specs/photos/spec.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/photos/spec.md diff --git a/openspec/changes/local-fs-storage-backend/specs/share-links/spec.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/share-links/spec.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/specs/share-links/spec.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/share-links/spec.md diff --git a/openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/storage-backends/spec.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/specs/storage-backends/spec.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/specs/storage-backends/spec.md diff --git a/openspec/changes/local-fs-storage-backend/tasks.md b/openspec/changes/archive/2026-10-11-local-fs-storage-backend/tasks.md similarity index 100% rename from openspec/changes/local-fs-storage-backend/tasks.md rename to openspec/changes/archive/2026-10-11-local-fs-storage-backend/tasks.md diff --git a/openspec/changes/sqlite-metadata-backend/.openspec.yaml b/openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/.openspec.yaml similarity index 100% rename from openspec/changes/sqlite-metadata-backend/.openspec.yaml rename to openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/.openspec.yaml diff --git a/openspec/changes/metadata-managed-jwt-secret/design.md b/openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/design.md similarity index 100% rename from openspec/changes/metadata-managed-jwt-secret/design.md rename to openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/design.md diff --git a/openspec/changes/metadata-managed-jwt-secret/proposal.md b/openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/proposal.md similarity index 100% rename from openspec/changes/metadata-managed-jwt-secret/proposal.md rename to openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/proposal.md diff --git a/openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md b/openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/specs/app-secrets/spec.md similarity index 100% rename from openspec/changes/metadata-managed-jwt-secret/specs/app-secrets/spec.md rename to openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/specs/app-secrets/spec.md diff --git a/openspec/changes/metadata-managed-jwt-secret/tasks.md b/openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/tasks.md similarity index 100% rename from openspec/changes/metadata-managed-jwt-secret/tasks.md rename to openspec/changes/archive/2026-10-11-metadata-managed-jwt-secret/tasks.md diff --git a/openspec/changes/storage-orphan-reconciliation/.openspec.yaml b/openspec/changes/archive/2026-10-11-sqlite-metadata-backend/.openspec.yaml similarity index 100% rename from openspec/changes/storage-orphan-reconciliation/.openspec.yaml rename to openspec/changes/archive/2026-10-11-sqlite-metadata-backend/.openspec.yaml diff --git a/openspec/changes/sqlite-metadata-backend/design.md b/openspec/changes/archive/2026-10-11-sqlite-metadata-backend/design.md similarity index 100% rename from openspec/changes/sqlite-metadata-backend/design.md rename to openspec/changes/archive/2026-10-11-sqlite-metadata-backend/design.md diff --git a/openspec/changes/sqlite-metadata-backend/proposal.md b/openspec/changes/archive/2026-10-11-sqlite-metadata-backend/proposal.md similarity index 100% rename from openspec/changes/sqlite-metadata-backend/proposal.md rename to openspec/changes/archive/2026-10-11-sqlite-metadata-backend/proposal.md diff --git a/openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md b/openspec/changes/archive/2026-10-11-sqlite-metadata-backend/specs/metadata-backends/spec.md similarity index 100% rename from openspec/changes/sqlite-metadata-backend/specs/metadata-backends/spec.md rename to openspec/changes/archive/2026-10-11-sqlite-metadata-backend/specs/metadata-backends/spec.md diff --git a/openspec/changes/sqlite-metadata-backend/tasks.md b/openspec/changes/archive/2026-10-11-sqlite-metadata-backend/tasks.md similarity index 100% rename from openspec/changes/sqlite-metadata-backend/tasks.md rename to openspec/changes/archive/2026-10-11-sqlite-metadata-backend/tasks.md diff --git a/openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/.openspec.yaml b/openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-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/archive/2026-10-11-storage-orphan-reconciliation/design.md similarity index 100% rename from openspec/changes/storage-orphan-reconciliation/design.md rename to openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/design.md diff --git a/openspec/changes/storage-orphan-reconciliation/proposal.md b/openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/proposal.md similarity index 100% rename from openspec/changes/storage-orphan-reconciliation/proposal.md rename to openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/proposal.md diff --git a/openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md b/openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/specs/storage-reconciliation/spec.md similarity index 100% rename from openspec/changes/storage-orphan-reconciliation/specs/storage-reconciliation/spec.md rename to openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/specs/storage-reconciliation/spec.md diff --git a/openspec/changes/storage-orphan-reconciliation/tasks.md b/openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/tasks.md similarity index 100% rename from openspec/changes/storage-orphan-reconciliation/tasks.md rename to openspec/changes/archive/2026-10-11-storage-orphan-reconciliation/tasks.md diff --git a/openspec/specs/app-secrets/spec.md b/openspec/specs/app-secrets/spec.md new file mode 100644 index 0000000..14db192 --- /dev/null +++ b/openspec/specs/app-secrets/spec.md @@ -0,0 +1,47 @@ +# app-secrets Specification + +## Purpose +Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный +секрет на инсталляцию без обязательной настройки окружения. + +## Requirements + +### Requirement: Хранение секрета приложения +Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один +и тот же секрет при последующих запусках. + +#### Scenario: Первый запуск +- **WHEN** секрет ещё не сохранён +- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных + +#### Scenario: Повторный запуск +- **WHEN** секрет уже сохранён +- **THEN** система использует сохранённый секрет и не генерирует новый + +### Requirement: Отсутствие ручного секрета +Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его +наличия при старте. + +#### Scenario: Секрет не задан в окружении +- **WHEN** переменная секрета не задана +- **THEN** система запускается, используя секрет из слоя метаданных + +### Requirement: Конкурентный первый запуск +При одновременном первом запуске нескольких инстансов на общей базе метаданных система +SHALL обеспечивать единый сохранённый секрет для всех инстансов. + +#### Scenario: Гонка первого запуска +- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета +- **THEN** сохраняется ровно один секрет, и оба инстанса используют его + +### Requirement: Использование секрета для подписи URL +При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя +метаданных. + +#### Scenario: URL_SECRET не задан +- **WHEN** отдельный секрет подписи URL не задан +- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных + +#### Scenario: URL_SECRET задан +- **WHEN** отдельный секрет подписи URL задан +- **THEN** он используется независимо от секрета приложения diff --git a/openspec/specs/metadata-backends/spec.md b/openspec/specs/metadata-backends/spec.md new file mode 100644 index 0000000..467b6a9 --- /dev/null +++ b/openspec/specs/metadata-backends/spec.md @@ -0,0 +1,79 @@ +# metadata-backends Specification + +## Purpose +Выбор и работа слоя метаданных: внешняя PostgreSQL или встроенная SQLite, с +паритетным поведением для одного экземпляра приложения. + +## Requirements + +### Requirement: Выбор бэкенда метаданных +Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации, +при этом встроенная SQLite SHALL быть значением по умолчанию. + +#### Scenario: Выбор по умолчанию +- **WHEN** бэкенд метаданных не задан в конфигурации +- **THEN** система использует встроенную SQLite с путём по умолчанию + +#### 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/specs/photos/spec.md b/openspec/specs/photos/spec.md index ba8aebb..7e63513 100644 --- a/openspec/specs/photos/spec.md +++ b/openspec/specs/photos/spec.md @@ -32,18 +32,19 @@ - **THEN** система возвращает не более лимита фото и курсор следующей страницы ### Requirement: Получение фото по требованию -Клиент SHALL иметь возможность получить актуальные данные фото с presigned URL. +Клиент SHALL иметь возможность получить актуальные данные фото с подписанным URL, +обслуживаемым бэкендом, независимо от бэкенда хранения. #### Scenario: Выдача свежего URL - **WHEN** клиент запрашивает фото по id -- **THEN** система возвращает свежий presigned URL (срок до 1 часа) для просмотра +- **THEN** система возвращает свежий подписанный URL бэкенда (срок до 1 часа) для просмотра ### Requirement: Скачивание одиночного фото Любой участник, включая viewer, SHALL иметь возможность скачать одиночное фото. #### Scenario: Скачивание фото - **WHEN** участник запрашивает скачивание фото -- **THEN** система возвращает presigned URL оригинала +- **THEN** система возвращает подписанный URL оригинала, обслуживаемый бэкендом ### Requirement: Удаление фото Автор SHALL иметь возможность удалить своё фото; владелец и админ SHALL иметь возможность удалить любое фото. Удаление SHALL помещать фото в корзину и SHALL не удалять объекты хранилища. @@ -68,7 +69,6 @@ - **WHEN** фото обработано, а обложка уже задана - **THEN** система не меняет обложку - ### Requirement: Переименование фото Автор фото, владелец и админ альбома SHALL иметь возможность изменить имя файла фото; остальные SHALL получать отказ, а пустое имя SHALL отклоняться. @@ -85,7 +85,6 @@ - **WHEN** участник без прав переименовывает чужое фото - **THEN** система отклоняет запрос - ### Requirement: Ограничение размера загрузки Система SHALL ограничивать размер загружаемых файлов и SHALL отвечать понятной ошибкой при превышении лимита, а не ошибкой разбора формы. @@ -98,7 +97,6 @@ - **WHEN** файл не превышает лимит - **THEN** система принимает multipart-загрузку и обрабатывает фото - ### Requirement: Метаданные фото (EXIF) При загрузке фото система SHALL извлекать распознаваемые EXIF-атрибуты и SHALL возвращать их вместе с данными фото; при отсутствии метаданных SHALL возвращать пустой набор. @@ -111,7 +109,6 @@ - **WHEN** загружается фото без читаемых EXIF - **THEN** данные фото не содержат атрибутов, а загрузка выполняется успешно - ### Requirement: Перенос и копирование фото между альбомами Пользователь с правом удаления в исходном альбоме и правом загрузки в целевом SHALL иметь возможность перенести или скопировать фото в другой альбом. Операции SHALL изменять только @@ -119,12 +116,11 @@ #### Scenario: Перенос - **WHEN** пользователь переносит фото в другой альбом -- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты S3 не меняются +- **THEN** фото исчезает из исходного альбома и появляется в целевом, объекты хранилища не меняются #### Scenario: Копирование - **WHEN** пользователь копирует фото в другой альбом -- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты S3 - +- **THEN** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты хранилища ### Requirement: Корзина фото Удаление фото SHALL помещать его в корзину (мягкое удаление). Восстановление SHALL возвращать @@ -147,7 +143,6 @@ - **WHEN** на объект хранилища ссылается копия в другом альбоме - **THEN** запись удаляется, а объект хранилища сохраняется - ### Requirement: Удаление альбома и восстановление фото без альбома Удаление альбома SHALL помещать его фото в корзину, не удаляя объекты хранилища. Если при восстановлении фото его альбом отсутствует, система SHALL предложить выбрать существующий @@ -161,7 +156,6 @@ - **WHEN** пользователь восстанавливает фото, у которого нет альбома - **THEN** система предлагает выбрать альбом или создать новый и восстанавливает фото в него - ### Requirement: Метки фото Пользователь SHALL присваивать фото свои метки (область — пользователь): создавать новые и выбирать из списка с поиском; метки SHALL отдаваться вместе с фото и сниматься. diff --git a/openspec/specs/share-links/spec.md b/openspec/specs/share-links/spec.md index 8c5305f..1c046a7 100644 --- a/openspec/specs/share-links/spec.md +++ b/openspec/specs/share-links/spec.md @@ -25,7 +25,7 @@ View-only доступ к альбому по ссылке без авториз #### Scenario: Просмотр по действующей ссылке - **WHEN** аноним открывает ссылку с действующим токеном -- **THEN** система возвращает фото альбома со свежими presigned URL +- **THEN** система возвращает фото альбома со свежими подписанными URL, обслуживаемыми бэкендом #### Scenario: Истёкшая или отозванная ссылка - **WHEN** аноним открывает ссылку с истёкшим или отозванным токеном diff --git a/openspec/specs/storage-backends/spec.md b/openspec/specs/storage-backends/spec.md new file mode 100644 index 0000000..9a70b83 --- /dev/null +++ b/openspec/specs/storage-backends/spec.md @@ -0,0 +1,111 @@ +# storage-backends Specification + +## Purpose +Выбор и работа объектного хранилища: S3-совместимое (Garage) или локальная файловая +система. Блобы всегда отдаются через бэкенд по подписанному URL, хранилище клиенту не +раскрывается. + +## Requirements + +### Requirement: Выбор бэкенда хранилища +Система SHALL поддерживать выбор бэкенда объектного хранилища параметром конфигурации, +при этом локальная файловая система SHALL быть значением по умолчанию. + +#### Scenario: Выбор по умолчанию +- **WHEN** бэкенд хранилища не задан в конфигурации +- **THEN** система хранит и отдаёт блобы из локальной файловой системы с корнем по умолчанию + +#### Scenario: Выбор S3-совместимого бэкенда +- **WHEN** в конфигурации задан S3-совместимый бэкенд +- **THEN** система хранит и отдаёт блобы через S3-совместимое хранилище + +#### Scenario: Выбор локальной ФС +- **WHEN** в конфигурации задан бэкенд локальной ФС +- **THEN** система хранит и отдаёт блобы из каталога локальной файловой системы + +#### Scenario: Неполная конфигурация +- **WHEN** конфигурация выбранного бэкенда неполна +- **THEN** система отказывается стартовать с явной ошибкой конфигурации + +### Requirement: Единая абстракция хранилища +Операции записи, чтения, удаления и проверки доступности SHALL выполняться через единый +интерфейс хранилища, одинаковый для всех бэкендов; ключи объектов SHALL оставаться +непрозрачными и независимыми от бэкенда. + +#### Scenario: Ключи переносимы между бэкендами +- **WHEN** один и тот же ключ объекта используется на разных бэкендах +- **THEN** он адресует тот же логический блоб без изменения формата + +#### Scenario: Удаление объекта +- **WHEN** система удаляет объект через интерфейс хранилища +- **THEN** блоб удаляется выбранным бэкендом + +### Requirement: Отдача блобов через бэкенд +Клиент SHALL получать любой блоб по URL, обслуживаемому бэкендом, с ограниченным сроком +действия; ни один бэкенд SHALL не отдавать клиенту ссылку напрямую на объектное +хранилище. Для анонимного доступа по share-ссылке подписанный URL SHALL быть действителен +без пользовательской сессии. + +#### Scenario: Свежий URL просмотра +- **WHEN** клиент запрашивает фото +- **THEN** система возвращает подписанный URL бэкенда со сроком действия до 1 часа + +#### Scenario: Доступ по share-ссылке +- **WHEN** аноним открывает альбом по действующей share-ссылке +- **THEN** полученные подписанные URL позволяют загрузить фото без сессии + +#### Scenario: Истёкшая подпись +- **WHEN** клиент обращается к URL с истёкшей или неверной подписью +- **THEN** система отказывает в доступе + +### Requirement: Хранилище не раскрывается клиенту +Адрес и учётные данные объектного хранилища SHALL оставаться внутренними; клиент SHALL +взаимодействовать с медиа только через бэкенд, независимо от бэкенда хранения. + +#### Scenario: S3-совместимый бэкенд +- **WHEN** хранилищем выбран S3-совместимый бэкенд +- **THEN** выдаваемый клиенту URL указывает на бэкенд, а не на S3-эндпоинт + +#### Scenario: Локальная ФС +- **WHEN** хранилищем выбрана локальная ФС +- **THEN** выдаваемый клиенту URL указывает на бэкенд + +#### Scenario: Хранилище недоступно извне +- **WHEN** объектное хранилище недоступно из внешней сети +- **THEN** просмотр и скачивание медиа продолжают работать + +### Requirement: Локальное хранение на ФС +Бэкенд локальной ФС SHALL хранить блобы в пределах заданного корневого каталога по +непрозрачным ключам объектов и SHALL отвергать ключи, выводящие за пределы корня. + +#### Scenario: Запись и чтение блоба +- **WHEN** система сохраняет объект с ключом +- **THEN** блоб размещается под корневым каталогом и позже читается по тому же ключу + +#### Scenario: Попытка выхода из корня +- **WHEN** ключ содержит элементы обхода каталога +- **THEN** система отвергает операцию и не обращается за пределы корня + +### Requirement: Диапазонные запросы и тип содержимого +При отдаче блоба система SHALL возвращать корректный тип содержимого и SHALL +поддерживать диапазонные запросы для докачки и больших файлов. + +#### Scenario: Запрос диапазона +- **WHEN** клиент запрашивает часть блоба через диапазон +- **THEN** система возвращает запрошенный диапазон и соответствующий статус + +#### Scenario: Тип содержимого +- **WHEN** клиент загружает блоб +- **THEN** ответ содержит тип содержимого, соответствующий объекту + +### Requirement: Доступность выбранного хранилища +Система SHALL сообщать о доступности выбранного бэкенда хранилища и отражать его в +состоянии здоровья сервиса. + +#### Scenario: Хранилище доступно +- **WHEN** выбранный бэкенд отвечает на проверку +- **THEN** состоянии здоровья отражает хранилище как доступное + +#### Scenario: Хранилище недоступно +- **WHEN** выбранный бэкенд не отвечает на проверку +- **THEN** состояние здоровья отражает деградацию diff --git a/openspec/specs/storage-reconciliation/spec.md b/openspec/specs/storage-reconciliation/spec.md new file mode 100644 index 0000000..26ec452 --- /dev/null +++ b/openspec/specs/storage-reconciliation/spec.md @@ -0,0 +1,64 @@ +# storage-reconciliation Specification + +## Purpose +Ручная сверка объектного хранилища с журналом метаданных: инвентаризация обеих сторон, +отчёт о расхождениях и безопасное удаление осиротевших блобов. + +## 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** она использует выбранные бэкенды хранения и метаданных без изменений кода -- 2.53.0 From e0b4db926f90c0c92f7296e3cdd6b9ca882b1d4a Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 10:34:30 +0300 Subject: [PATCH 5/5] feat(server): log metadata and storage backends at startup --- backend/cmd/server/main.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d4fd657..0a72ec5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -37,6 +37,21 @@ func main() { os.Exit(1) } + switch cfg.Database.Backend { + case "sqlite": + slog.Info("metadata backend", "backend", "sqlite", "path", cfg.Database.SQLitePath) + default: + slog.Info("metadata backend", "backend", "postgres", + "host", cfg.Postgres.Host, "database", cfg.Postgres.Database) + } + switch cfg.Storage.Backend { + case "fs": + slog.Info("storage backend", "backend", "fs", "root", cfg.Storage.FSRoot) + default: + slog.Info("storage backend", "backend", "s3", + "endpoint", cfg.S3.Endpoint, "bucket", cfg.S3.Bucket) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() -- 2.53.0