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 со свайпами.