feat: local-first storage and metadata backends #3
+11
-3
@@ -6,15 +6,23 @@ PG_PASSWORD=change_me
|
|||||||
PG_DATABASE=shared_album
|
PG_DATABASE=shared_album
|
||||||
PG_SSLMODE=disable
|
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_ENDPOINT=http://s3.localnet:3900
|
||||||
S3_REGION=garage
|
S3_REGION=garage
|
||||||
S3_ACCESS_KEY=GKchange_me
|
S3_ACCESS_KEY=GKchange_me
|
||||||
S3_SECRET_KEY=change_me
|
S3_SECRET_KEY=change_me
|
||||||
S3_BUCKET=galo-data
|
S3_BUCKET=galo-data
|
||||||
S3_USE_PATH_STYLE=true
|
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/<key>.
|
||||||
|
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
|
# Auth
|
||||||
JWT_SECRET=change_me_long_random
|
JWT_SECRET=change_me_long_random
|
||||||
|
|||||||
@@ -129,11 +129,11 @@ backend/
|
|||||||
cmd/migrate/ # применение миграций
|
cmd/migrate/ # применение миграций
|
||||||
internal/config # чтение env
|
internal/config # чтение env
|
||||||
internal/db # pgx + golang-migrate
|
internal/db # pgx + golang-migrate
|
||||||
internal/storage # S3 (Garage)
|
internal/storage # S3 (Garage) / локальная ФС, отдача через бэкенд
|
||||||
internal/auth # JWT, bcrypt, refresh
|
internal/auth # JWT, bcrypt, refresh
|
||||||
internal/albums # альбомы
|
internal/albums # альбомы
|
||||||
internal/members # участники, роли, инвайты
|
internal/members # участники, роли, инвайты
|
||||||
internal/photos # загрузка, варианты, presigned URL
|
internal/photos # загрузка, варианты, отдача через бэкенд
|
||||||
internal/social # лайки, комментарии
|
internal/social # лайки, комментарии
|
||||||
internal/notifications # персональные уведомления
|
internal/notifications # персональные уведомления
|
||||||
internal/sharelinks # ссылки для просмотра
|
internal/sharelinks # ссылки для просмотра
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
store, err := storage.New(ctx, cfg.S3)
|
store, err := storage.Open(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("storage init failed", "error", err)
|
slog.Error("storage init failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
store, err := storage.New(ctx, cfg.S3)
|
store, err := storage.Open(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("storage init failed", "error", err)
|
slog.Error("storage init failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ type Service struct {
|
|||||||
store *Store
|
store *Store
|
||||||
members MemberLookup
|
members MemberLookup
|
||||||
hub *sse.Hub
|
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}
|
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
|
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) {
|
func (s *Service) attachCover(ctx context.Context, a *models.Album) {
|
||||||
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
|
if s.storage == nil || a == nil || a.CoverThumbKey == "" {
|
||||||
return
|
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
|
a.CoverThumbURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,7 +28,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
storage *storage.Client
|
storage storage.Backend
|
||||||
hub *sse.Hub
|
hub *sse.Hub
|
||||||
|
|
||||||
albumsStore *albums.Store
|
albumsStore *albums.Store
|
||||||
@@ -47,7 +48,7 @@ type Server struct {
|
|||||||
share *sharelinks.Service
|
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)
|
authStore := auth.NewStore(pool)
|
||||||
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
|
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
|
||||||
authSvc := auth.NewService(authStore, tm)
|
authSvc := auth.NewService(authStore, tm)
|
||||||
@@ -109,6 +110,8 @@ func (s *Server) Router() *gin.Engine {
|
|||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
api.GET("/health", s.health)
|
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/register", authHandler.Register)
|
||||||
api.POST("/auth/login", authHandler.Login)
|
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 {
|
if err := s.pool.Ping(ctx); err != nil {
|
||||||
dbStatus = "error"
|
dbStatus = "error"
|
||||||
}
|
}
|
||||||
s3Status := "ok"
|
storageLabel := s.cfg.Storage.Backend
|
||||||
|
if storageLabel == "" {
|
||||||
|
storageLabel = "s3"
|
||||||
|
}
|
||||||
|
storeStatus := "ok"
|
||||||
if err := s.storage.Ping(ctx); err != nil {
|
if err := s.storage.Ping(ctx); err != nil {
|
||||||
s3Status = "error"
|
storeStatus = "error"
|
||||||
}
|
}
|
||||||
status := http.StatusOK
|
status := http.StatusOK
|
||||||
overall := "ok"
|
overall := "ok"
|
||||||
if dbStatus != "ok" || s3Status != "ok" {
|
if dbStatus != "ok" || storeStatus != "ok" {
|
||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
overall = "degraded"
|
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) {
|
func (s *Server) albumSSE(c *gin.Context) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,8 +28,6 @@ type S3 struct {
|
|||||||
SecretKey string
|
SecretKey string
|
||||||
Bucket string
|
Bucket string
|
||||||
UsePathStyle bool
|
UsePathStyle bool
|
||||||
PublicURL string
|
|
||||||
PresignTTL time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
@@ -54,9 +52,17 @@ type Worker struct {
|
|||||||
PendingTTL time.Duration
|
PendingTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
Backend string
|
||||||
|
FSRoot string
|
||||||
|
URLSecret string
|
||||||
|
URLTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Postgres Postgres
|
Postgres Postgres
|
||||||
S3 S3
|
S3 S3
|
||||||
|
Storage Storage
|
||||||
Auth Auth
|
Auth Auth
|
||||||
Server Server
|
Server Server
|
||||||
SSE SSE
|
SSE SSE
|
||||||
@@ -79,9 +85,9 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
|
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 {
|
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"))
|
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
|
||||||
@@ -130,8 +136,12 @@ func Load() (*Config, error) {
|
|||||||
SecretKey: getEnv("S3_SECRET_KEY", ""),
|
SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||||
Bucket: getEnv("S3_BUCKET", "albums"),
|
Bucket: getEnv("S3_BUCKET", "albums"),
|
||||||
UsePathStyle: pathStyle,
|
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{
|
Auth: Auth{
|
||||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||||
@@ -160,6 +170,16 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("PG_PASSWORD is required")
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -100,29 +100,47 @@ func makePNG(t *testing.T) []byte {
|
|||||||
|
|
||||||
// newTestService builds a service backed by the real dev storage. It skips when
|
// newTestService builds a service backed by the real dev storage. It skips when
|
||||||
// S3 env is absent so unit runs stay hermetic.
|
// 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()
|
t.Helper()
|
||||||
endpoint := os.Getenv("S3_ENDPOINT")
|
endpoint := os.Getenv("S3_ENDPOINT")
|
||||||
if endpoint == "" {
|
if endpoint == "" {
|
||||||
t.Skip("S3_ENDPOINT not set")
|
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,
|
Endpoint: endpoint,
|
||||||
Region: testEnvOr("S3_REGION", "garage"),
|
Region: testEnvOr("S3_REGION", "garage"),
|
||||||
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
AccessKey: os.Getenv("S3_ACCESS_KEY"),
|
||||||
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
SecretKey: os.Getenv("S3_SECRET_KEY"),
|
||||||
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
|
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
|
||||||
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
|
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
|
||||||
PresignTTL: time.Hour,
|
}, signer)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Skipf("storage init: %v", err)
|
t.Skipf("storage init: %v", err)
|
||||||
}
|
}
|
||||||
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
|
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.
|
// 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()
|
t.Helper()
|
||||||
rows, err := pool.Query(ctx,
|
rows, err := pool.Query(ctx,
|
||||||
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
|
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ type AlbumAccess interface {
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
albums AlbumAccess
|
albums AlbumAccess
|
||||||
storage *storage.Client
|
storage storage.Backend
|
||||||
hub *sse.Hub
|
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}
|
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 {
|
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||||
if p.StorageKey != "" {
|
if p.StorageKey != "" {
|
||||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
url, err := s.storage.URL(ctx, p.StorageKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -126,12 +126,12 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
|||||||
p.DownloadURL = url
|
p.DownloadURL = url
|
||||||
}
|
}
|
||||||
if p.ThumbKey != "" {
|
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
|
p.ThumbURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.PreviewKey != "" {
|
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
|
p.PreviewURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,13 +202,13 @@ func (s *Service) GetPublic(ctx context.Context, photoID string) (*models.Photo,
|
|||||||
return p, nil
|
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) {
|
func (s *Service) DownloadPublic(ctx context.Context, photoID string) (string, error) {
|
||||||
p, err := s.GetPublic(ctx, photoID)
|
p, err := s.GetPublic(ctx, photoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
url, err := s.storage.PresignGet(ctx, p.StorageKey)
|
url, err := s.storage.URL(ctx, p.StorageKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"shared-album/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
||||||
pool := testPool(t)
|
pool := testPool(t)
|
||||||
ctx := context.Background()
|
|
||||||
svc, cl := newTestService(t, pool)
|
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)
|
user := createUser(t, ctx, pool)
|
||||||
album := createAlbum(t, ctx, pool, user)
|
album := createAlbum(t, ctx, pool, user)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, "/")
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
@@ -18,14 +19,49 @@ import (
|
|||||||
"shared-album/internal/config"
|
"shared-album/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client struct {
|
// ErrSignedURLInvalid is returned when an app-served object URL has a missing,
|
||||||
s3 *s3.Client
|
// malformed, expired or forged signature.
|
||||||
presign *s3.PresignClient
|
var ErrSignedURLInvalid = errors.New("invalid or expired object signature")
|
||||||
bucket string
|
|
||||||
presignTTL time.Duration
|
// 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,
|
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
|
||||||
awsconfig.WithRegion(cfg.Region),
|
awsconfig.WithRegion(cfg.Region),
|
||||||
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
|
||||||
@@ -41,17 +77,38 @@ func New(ctx context.Context, cfg config.S3) (*Client, error) {
|
|||||||
o.UsePathStyle = cfg.UsePathStyle
|
o.UsePathStyle = cfg.UsePathStyle
|
||||||
})
|
})
|
||||||
|
|
||||||
return &Client{
|
return &S3{
|
||||||
s3: client,
|
s3: client,
|
||||||
presign: s3.NewPresignClient(client),
|
bucket: cfg.Bucket,
|
||||||
bucket: cfg.Bucket,
|
signer: signer,
|
||||||
presignTTL: cfg.PresignTTL,
|
|
||||||
}, nil
|
}, 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)})
|
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -66,12 +123,12 @@ func (c *Client) EnsureBucket(ctx context.Context) error {
|
|||||||
return nil
|
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)})
|
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
|
||||||
return err
|
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{
|
in := &s3.PutObjectInput{
|
||||||
Bucket: aws.String(c.bucket),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
@@ -87,7 +144,7 @@ func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64
|
|||||||
return nil
|
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 {
|
if len(keys) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -104,7 +161,7 @@ func (c *Client) Delete(ctx context.Context, keys ...string) error {
|
|||||||
return nil
|
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{
|
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||||
Bucket: aws.String(c.bucket),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
@@ -120,14 +177,97 @@ func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
|
|||||||
return data, nil
|
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),
|
Bucket: aws.String(c.bucket),
|
||||||
Key: aws.String(key),
|
Key: aws.String(key),
|
||||||
}, s3.WithPresignExpires(c.presignTTL))
|
})
|
||||||
if err != nil {
|
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 {
|
func isNoSuchBucket(err error) bool {
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ server {
|
|||||||
chunked_transfer_encoding on;
|
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 / {
|
location / {
|
||||||
proxy_pass http://shared_album;
|
proxy_pass http://shared_album;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -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/<key>?e=<exp>&s=<sig>`. 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.
|
||||||
@@ -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/<key>` с 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`.
|
||||||
|
- Не затрагивает слой метаданных.
|
||||||
@@ -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** в целевом альбоме появляется новая запись, ссылающаяся на те же объекты хранилища
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Просмотр альбома по ссылке
|
||||||
|
Пользователь без авторизации SHALL иметь возможность просматривать альбом по действующему токену (сетка, лайтбокс, скачивание одиночного фото).
|
||||||
|
|
||||||
|
#### Scenario: Просмотр по действующей ссылке
|
||||||
|
- **WHEN** аноним открывает ссылку с действующим токеном
|
||||||
|
- **THEN** система возвращает фото альбома со свежими подписанными URL, обслуживаемыми бэкендом
|
||||||
|
|
||||||
|
#### Scenario: Истёкшая или отозванная ссылка
|
||||||
|
- **WHEN** аноним открывает ссылку с истёкшим или отозванным токеном
|
||||||
|
- **THEN** система отклоняет доступ
|
||||||
@@ -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** состояние здоровья отражает деградацию
|
||||||
@@ -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) с раскладкой `<root>/<key>` и нормализацией пути; проверка — юнит-тесты записи, чтения, удаления и отказа на `..`/абсолютный ключ проходят.
|
||||||
|
- [x] 2.2 Реализовать `URL` с HMAC-SHA256 подписью (`key`+`exp`, `URL_SECRET` с fallback на `JWT_SECRET`) и сроком `OBJECT_URL_TTL`; проверка — юнит-тесты валидной, истёкшей и подделанной подписи проходят.
|
||||||
|
|
||||||
|
## 3. Маршрут отдачи объектов
|
||||||
|
|
||||||
|
- [x] 3.1 Добавить публичный `GET /api/objects/<key>` с проверкой подписи и срока и стримингом через `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.
|
||||||
+25
-6
@@ -144,14 +144,19 @@ PG_PASSWORD=change_me
|
|||||||
PG_DATABASE=shared_album
|
PG_DATABASE=shared_album
|
||||||
PG_SSLMODE=disable
|
PG_SSLMODE=disable
|
||||||
|
|
||||||
# S3 (Garage)
|
# S3 (Garage) - внутреннее хранилище, клиенту не раскрывается
|
||||||
S3_ENDPOINT=http://s3.localnet:3900
|
S3_ENDPOINT=http://s3.localnet:3900
|
||||||
S3_REGION=garage
|
S3_REGION=garage
|
||||||
S3_ACCESS_KEY=GKchange_me
|
S3_ACCESS_KEY=GKchange_me
|
||||||
S3_SECRET_KEY=change_me
|
S3_SECRET_KEY=change_me
|
||||||
S3_BUCKET=albums
|
S3_BUCKET=albums
|
||||||
S3_USE_PATH_STYLE=true
|
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
|
# Auth
|
||||||
JWT_SECRET=change_me_long_random
|
JWT_SECRET=change_me_long_random
|
||||||
@@ -339,6 +344,11 @@ colors: {
|
|||||||
|
|
||||||
## 10. Слой хранения и слой метаданных
|
## 10. Слой хранения и слой метаданных
|
||||||
|
|
||||||
|
**Слой хранения выбирается параметром `STORAGE_BACKEND`:** `s3` (S3-совместимое
|
||||||
|
хранилище, по умолчанию) или `fs` (локальная файловая система, `FS_ROOT`). Оба
|
||||||
|
бэкенда реализуют единый интерфейс `storage.Backend` и используют одни и те же
|
||||||
|
непрозрачные ключи, поэтому переключение не меняет формат данных.
|
||||||
|
|
||||||
**S3 (Garage) — непрозрачное объектное хранилище.** Ключи не содержат сущностей
|
**S3 (Garage) — непрозрачное объектное хранилище.** Ключи не содержат сущностей
|
||||||
метаданных (альбома, фото). Каждый физический файл адресуется своим `object_id`:
|
метаданных (альбома, фото). Каждый физический файл адресуется своим `object_id`:
|
||||||
|
|
||||||
@@ -350,6 +360,11 @@ colors: {
|
|||||||
└── preview.webp
|
└── preview.webp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
При `STORAGE_BACKEND=fs` те же ключи отображаются в файлы под `FS_ROOT`. Отдача
|
||||||
|
идёт через сам сервис: `GET /api/objects/<key>` с HMAC-подписью (`e`, `s`),
|
||||||
|
поддержкой Range и Content-Type. Один и тот же маршрут обслуживает авторизованных
|
||||||
|
пользователей и анонимные share-ссылки.
|
||||||
|
|
||||||
**Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime,
|
**Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime,
|
||||||
размер, габариты, checksum, состояние `pending|ready|deleting`), таблица
|
размер, габариты, checksum, состояние `pending|ready|deleting`), таблица
|
||||||
`photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`),
|
`photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`),
|
||||||
@@ -358,13 +373,16 @@ colors: {
|
|||||||
|
|
||||||
Следствия:
|
Следствия:
|
||||||
- **Копирование** между альбомами — только новые строки `photos`+`photo_objects`,
|
- **Копирование** между альбомами — только новые строки `photos`+`photo_objects`,
|
||||||
ссылающиеся на те же объекты; операции с S3 не выполняются.
|
ссылающиеся на те же объекты; операции с хранилищем не выполняются.
|
||||||
- **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются.
|
- **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются.
|
||||||
- **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на
|
- **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на
|
||||||
объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям).
|
объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям).
|
||||||
|
|
||||||
Доступ: **presigned URL** со сроком 1 час (бакет приватный).
|
Доступ: **только через бэкенд** — подписанный URL со сроком 1 час на
|
||||||
Garage поддерживает presigned URL через SigV4 query-string signatures.
|
`/api/objects/<key>`, независимо от бэкенда хранилища. Для S3 (Garage) байты
|
||||||
|
стримятся из внутренней сети через бэкенд (ranged `GetObject`), presigned URL наружу
|
||||||
|
не выдаются; для ФС — чтение с диска. Переключение бэкенда не переносит существующие
|
||||||
|
блобы — перенос выполняется вручную.
|
||||||
|
|
||||||
## 11. API-эндпоинты
|
## 11. API-эндпоинты
|
||||||
|
|
||||||
@@ -609,7 +627,8 @@ invites, photos, comments, likes, notifications. Применяются к pg.lo
|
|||||||
Реализуй загрузку фото в Garage с генерацией трёх превью
|
Реализуй загрузку фото в Garage с генерацией трёх превью
|
||||||
(thumb 200px, preview 1200px, original) и извлечением EXIF.
|
(thumb 200px, preview 1200px, original) и извлечением EXIF.
|
||||||
Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}.
|
Структура ключей: albums/{album_id}/{original|preview|thumb}/{photo_id}.{ext|webp}.
|
||||||
Отдавай фото через presigned URL (срок 1 час).
|
Отдавай фото через бэкенд: подписанный URL `/api/objects/<key>` (срок 1 час),
|
||||||
|
S3-байты стримятся из внутренней сети через бэкенд.
|
||||||
Эндпоинты загрузки, списка с cursor-пагинацией, удаления.
|
Эндпоинты загрузки, списка с cursor-пагинацией, удаления.
|
||||||
На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой,
|
На фронтенде — Uploader с drag&drop и прогрессом, PhotoGrid с ленивой загрузкой,
|
||||||
Lightbox со свайпами.
|
Lightbox со свайпами.
|
||||||
|
|||||||
Reference in New Issue
Block a user