feat: local-first storage and metadata backends #3
+3
-3
@@ -24,13 +24,13 @@ S3_USE_PATH_STYLE=true
|
||||
STORAGE_BACKEND=fs
|
||||
# Root directory for object blobs (STORAGE_BACKEND=fs)
|
||||
FS_ROOT=./data
|
||||
# Signs object URLs; falls back to JWT_SECRET when empty
|
||||
# Signs object URLs; falls back to the metadata-layer secret when empty
|
||||
URL_SECRET=
|
||||
# Lifetime of signed object URLs
|
||||
OBJECT_URL_TTL=1h
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change_me_long_random
|
||||
# Auth. The token signing secret is generated on first start and stored in the
|
||||
# metadata layer (app_settings); it is not configured here.
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=720h
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@ cp .env.example .env
|
||||
```
|
||||
|
||||
Ключевые переменные: `PG_*`, `S3_*` (обязательны `UsePathStyle=true` и регион
|
||||
`garage`), `JWT_SECRET`, `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
|
||||
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`.
|
||||
`garage`), `JWT_ACCESS_TTL`, `JWT_REFRESH_TTL`, `HTTP_ADDR`,
|
||||
`SSE_HEARTBEAT_INTERVAL`, `SSE_CLIENT_BUFFER`. Секрет подписи токенов не задаётся
|
||||
в окружении: он генерируется при первом старте и хранится в слое метаданных
|
||||
(`app_settings`).
|
||||
|
||||
Слои данных выбираются параметрами (по умолчанию — локальный режим):
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
"shared-album/internal/config"
|
||||
"shared-album/internal/db"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/storage"
|
||||
"shared-album/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -35,14 +36,20 @@ func main() {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
store, err := storage.Open(ctx, cfg)
|
||||
repos := store.NewPostgres(pool)
|
||||
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
||||
if err != nil {
|
||||
slog.Error("secret init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
blob, err := storage.Open(ctx, cfg, secret)
|
||||
if err != nil {
|
||||
slog.Error("storage init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
photoStore := photos.NewStore(pool)
|
||||
updated, err := photoStore.BackfillEXIF(ctx, store.Get)
|
||||
updated, err := repos.Photos.BackfillEXIF(ctx, blob.Get)
|
||||
if err != nil {
|
||||
slog.Error("backfill failed", "error", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"shared-album/internal/config"
|
||||
"shared-album/internal/db"
|
||||
"shared-album/internal/reconcile"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/storage"
|
||||
"shared-album/internal/store"
|
||||
)
|
||||
@@ -50,7 +51,13 @@ func main() {
|
||||
}
|
||||
defer closer()
|
||||
|
||||
blob, err := storage.Open(ctx, cfg)
|
||||
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
||||
if err != nil {
|
||||
slog.Error("secret init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
blob, err := storage.Open(ctx, cfg, secret)
|
||||
if err != nil {
|
||||
slog.Error("storage init failed", "error", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
|
||||
assets "shared-album"
|
||||
"shared-album/internal/app"
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/config"
|
||||
"shared-album/internal/db"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
"shared-album/internal/store"
|
||||
@@ -84,7 +86,14 @@ func main() {
|
||||
defer closer()
|
||||
slog.Info("migrations applied", "db_backend", cfg.Database.Backend)
|
||||
|
||||
blob, err := storage.Open(ctx, cfg)
|
||||
secret, err := repos.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
||||
if err != nil {
|
||||
slog.Error("secret init failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
tm := auth.NewTokenManager(secret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
|
||||
|
||||
blob, err := storage.Open(ctx, cfg, secret)
|
||||
if err != nil {
|
||||
slog.Error("storage init failed", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -96,7 +105,7 @@ func main() {
|
||||
}
|
||||
|
||||
hub := sse.NewHub(cfg.SSE.ClientBuffer)
|
||||
server := app.New(cfg, repos, ping, blob, hub)
|
||||
server := app.New(cfg, repos, ping, tm, blob, hub)
|
||||
|
||||
go server.StartWorker(ctx)
|
||||
|
||||
|
||||
@@ -50,8 +50,7 @@ type Server struct {
|
||||
|
||||
// New wires the domain services over the selected metadata repositories. ping
|
||||
// reports the health of the metadata backend so health stays backend-agnostic.
|
||||
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, st storage.Backend, hub *sse.Hub) *Server {
|
||||
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
|
||||
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, tm *auth.TokenManager, st storage.Backend, hub *sse.Hub) *Server {
|
||||
authSvc := auth.NewService(repos.Auth, tm)
|
||||
|
||||
notifSvc := notifications.NewService(repos.Notifications, hub)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/config"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
@@ -30,7 +31,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
|
||||
t.Fatalf("new fs: %v", err)
|
||||
}
|
||||
noop := func(context.Context) error { return nil }
|
||||
srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1))
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
|
||||
srv := New(&config.Config{}, store.Repos{}, noop, tm, fs, sse.NewHub(1))
|
||||
return fs, srv.Router()
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,8 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
hub := sse.NewHub(1)
|
||||
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub)
|
||||
tm := auth.NewTokenManager("test-secret", 15*time.Minute, time.Hour)
|
||||
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, tm, backend, hub)
|
||||
router := srv.Router()
|
||||
|
||||
authStore := auth.NewStore(pool)
|
||||
|
||||
@@ -31,7 +31,6 @@ type S3 struct {
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
JWTSecret string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
}
|
||||
@@ -154,7 +153,6 @@ func Load() (*Config, error) {
|
||||
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
|
||||
},
|
||||
Auth: Auth{
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
AccessTTL: accessTTL,
|
||||
RefreshTTL: refreshTTL,
|
||||
},
|
||||
@@ -173,10 +171,6 @@ func Load() (*Config, error) {
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Auth.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
|
||||
switch cfg.Database.Backend {
|
||||
case "postgres":
|
||||
if cfg.Postgres.Password == "" {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"shared-album/internal/auth"
|
||||
"shared-album/internal/db"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sse"
|
||||
"shared-album/internal/storage"
|
||||
"shared-album/internal/store"
|
||||
@@ -137,6 +138,53 @@ func TestSQLiteEndToEndWithRestart(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestJWTSecretPersistsAcrossRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "galo.db")
|
||||
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("migrations fs: %v", err)
|
||||
}
|
||||
|
||||
open := func() (*sql.DB, store.Repos) {
|
||||
t.Helper()
|
||||
if err := db.MigrateSQLite(path, migFS); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
database, err := db.OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
return database, store.NewSQLite(database)
|
||||
}
|
||||
|
||||
db1, repos1 := open()
|
||||
secret1, err := repos1.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
||||
if err != nil || secret1 == "" {
|
||||
t.Fatalf("ensure secret: %v %q", err, secret1)
|
||||
}
|
||||
tm1 := auth.NewTokenManager(secret1, 15*time.Minute, time.Hour)
|
||||
token, err := tm1.GenerateAccess("user-1", "u@test.local")
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
_ = db1.Close()
|
||||
|
||||
db2, repos2 := open()
|
||||
defer db2.Close()
|
||||
secret2, err := repos2.Settings.Ensure(ctx, settings.JWTSecretKey, settings.GenerateSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("ensure after restart: %v", err)
|
||||
}
|
||||
if secret2 != secret1 {
|
||||
t.Fatalf("secret changed across restart")
|
||||
}
|
||||
tm2 := auth.NewTokenManager(secret2, 15*time.Minute, time.Hour)
|
||||
if _, err := tm2.ParseAccess(token); err != nil {
|
||||
t.Fatalf("token issued before restart not valid after: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"shared-album/internal/models"
|
||||
"shared-album/internal/notifications"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/social"
|
||||
)
|
||||
@@ -29,6 +30,7 @@ type Repos struct {
|
||||
Social social.Repository
|
||||
Notifications notifications.Repository
|
||||
ShareLinks sharelinks.Repository
|
||||
Settings settings.Repository
|
||||
Reset func(ctx context.Context) error
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"Objects", testObjects},
|
||||
{"Settings", testSettings},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -523,6 +526,30 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
func testSettings(t *testing.T, ctx context.Context, r Repos) {
|
||||
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
|
||||
if err != nil || v != "generated-1" {
|
||||
t.Fatalf("ensure first: %v %q", err, v)
|
||||
}
|
||||
v2, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-2", nil })
|
||||
if err != nil || v2 != "generated-1" {
|
||||
t.Fatalf("ensure not idempotent: %v %q", err, v2)
|
||||
}
|
||||
got, ok, err := r.Settings.Get(ctx, settings.JWTSecretKey)
|
||||
if err != nil || !ok || got != "generated-1" {
|
||||
t.Fatalf("get: %v ok=%v %q", err, ok, got)
|
||||
}
|
||||
if _, ok, _ := r.Settings.Get(ctx, "missing"); ok {
|
||||
t.Fatalf("missing key reported present")
|
||||
}
|
||||
if err := r.Settings.Set(ctx, settings.JWTSecretKey, "rotated"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if got, _, _ := r.Settings.Get(ctx, settings.JWTSecretKey); got != "rotated" {
|
||||
t.Fatalf("set not applied: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/notifications"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/social"
|
||||
)
|
||||
@@ -70,8 +71,9 @@ func TestPostgresContract(t *testing.T) {
|
||||
Social: social.NewStore(pool),
|
||||
Notifications: notifications.NewStore(pool),
|
||||
ShareLinks: sharelinks.NewStore(pool),
|
||||
Settings: settings.NewStore(pool),
|
||||
Reset: func(ctx context.Context) error {
|
||||
_, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`)
|
||||
_, err := pool.Exec(ctx, `TRUNCATE users, objects, app_settings CASCADE`)
|
||||
return err
|
||||
},
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/notifications"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/social"
|
||||
)
|
||||
@@ -40,11 +41,12 @@ func TestSQLiteContract(t *testing.T) {
|
||||
Social: social.NewSQLiteStore(database),
|
||||
Notifications: notifications.NewSQLiteStore(database),
|
||||
ShareLinks: sharelinks.NewSQLiteStore(database),
|
||||
Settings: settings.NewSQLiteStore(database),
|
||||
Reset: func(ctx context.Context) error {
|
||||
tables := []string{
|
||||
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
|
||||
"refresh_tokens", "share_links", "notifications", "likes", "comments",
|
||||
"invites", "album_members", "photos", "albums", "users",
|
||||
"invites", "album_members", "photos", "albums", "users", "app_settings",
|
||||
}
|
||||
for _, tbl := range tables {
|
||||
if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// GenerateSecret returns a new random secret suitable for HMAC signing.
|
||||
func GenerateSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Package settings persists application-level key/value settings in the
|
||||
// metadata layer. The main use is the token signing secret, generated on first
|
||||
// use and reused afterwards.
|
||||
package settings
|
||||
|
||||
import "context"
|
||||
|
||||
// JWTSecretKey is the settings key holding the token signing secret.
|
||||
const JWTSecretKey = "jwt_secret"
|
||||
|
||||
// Repository reads and writes application settings.
|
||||
type Repository interface {
|
||||
// Get returns the value for key and whether it exists.
|
||||
Get(ctx context.Context, key string) (string, bool, error)
|
||||
// Set stores or replaces the value for key.
|
||||
Set(ctx context.Context, key, value string) error
|
||||
// Ensure returns the existing value for key, or generates and stores one
|
||||
// using generate when absent. Concurrent first use yields one value.
|
||||
Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"shared-album/internal/sqliteutil"
|
||||
)
|
||||
|
||||
// SQLiteStore is the SQLite implementation of Repository.
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
|
||||
|
||||
func (s *SQLiteStore) Get(ctx context.Context, key string) (string, bool, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Set(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = $3`,
|
||||
key, value, sqliteutil.FormatTime(time.Now()))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
|
||||
if v, ok, err := s.Get(ctx, key); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return v, nil
|
||||
}
|
||||
v, err := generate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
|
||||
key, v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
got, ok, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("settings: %q missing after insert", key)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store is the PostgreSQL implementation of Repository.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Get(ctx context.Context, key string) (string, bool, error) {
|
||||
var v string
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, key).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return v, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, key, value string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Ensure(ctx context.Context, key string, generate func() (string, error)) (string, error) {
|
||||
if v, ok, err := s.Get(ctx, key); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return v, nil
|
||||
}
|
||||
v, err := generate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO app_settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`,
|
||||
key, v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
got, ok, err := s.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("settings: %q missing after insert", key)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
@@ -98,11 +98,15 @@ func (c *S3) Bucket() string { return c.bucket }
|
||||
|
||||
// Open builds the storage backend selected by configuration: S3-compatible by
|
||||
// default, or the local file system when STORAGE_BACKEND=fs. Every backend
|
||||
// delivers blobs through the app using a shared Signer.
|
||||
func Open(ctx context.Context, cfg *config.Config) (Backend, error) {
|
||||
// delivers blobs through the app using a shared Signer. jwtSecret is the
|
||||
// application secret used to sign object URLs when URL_SECRET is not set.
|
||||
func Open(ctx context.Context, cfg *config.Config, jwtSecret string) (Backend, error) {
|
||||
secret := cfg.Storage.URLSecret
|
||||
if secret == "" {
|
||||
secret = cfg.Auth.JWTSecret
|
||||
secret = jwtSecret
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, errors.New("object URL secret is not available")
|
||||
}
|
||||
signer, err := NewSigner(cfg.Server.BaseURL, secret, cfg.Storage.URLTTL)
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"shared-album/internal/members"
|
||||
"shared-album/internal/notifications"
|
||||
"shared-album/internal/photos"
|
||||
"shared-album/internal/settings"
|
||||
"shared-album/internal/sharelinks"
|
||||
"shared-album/internal/social"
|
||||
)
|
||||
@@ -26,6 +27,7 @@ type Repos struct {
|
||||
Social social.Repository
|
||||
Notifications notifications.Repository
|
||||
ShareLinks sharelinks.Repository
|
||||
Settings settings.Repository
|
||||
}
|
||||
|
||||
// NewPostgres builds the repositories backed by PostgreSQL.
|
||||
@@ -38,6 +40,7 @@ func NewPostgres(pool *pgxpool.Pool) Repos {
|
||||
Social: social.NewStore(pool),
|
||||
Notifications: notifications.NewStore(pool),
|
||||
ShareLinks: sharelinks.NewStore(pool),
|
||||
Settings: settings.NewStore(pool),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,5 +54,6 @@ func NewSQLite(db *sql.DB) Repos {
|
||||
Social: social.NewSQLiteStore(db),
|
||||
Notifications: notifications.NewSQLiteStore(db),
|
||||
ShareLinks: sharelinks.NewSQLiteStore(db),
|
||||
Settings: settings.NewSQLiteStore(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS app_settings;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS app_settings;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-11
|
||||
@@ -0,0 +1,76 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Текущее состояние:
|
||||
|
||||
- `JWT_SECRET` обязателен в env (`config.Load`), используется в `auth.NewTokenManager`
|
||||
(HS256-подпись access-токенов) и как fallback для `URL_SECRET` в `storage.Open`.
|
||||
- Refresh-токены не подписываются секретом (случайные, хранятся хэшем).
|
||||
- Есть два бэкенда метаданных (`postgres`, `sqlite`) за интерфейсом `Repository` и
|
||||
фабрикой `store.NewPostgres/NewSQLite`; миграции раздельные (pg-набор и `migrations/sqlite`).
|
||||
- Порядок старта в `cmd/server`: миграции → метаданные → storage → app.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Хранить секрет приложения в слое метаданных; генерировать при первом использовании.
|
||||
- Убрать обязательный `JWT_SECRET` из окружения.
|
||||
- Единый стабильный секрет на инсталляцию; корректное поведение при конкурентном старте.
|
||||
|
||||
**Non-Goals:**
|
||||
- Шифрование секрета в БД (БД доверенная).
|
||||
- UI/команда ротации секрета.
|
||||
- Миграция существующих env-секретов в БД.
|
||||
- Изменение формата токенов или наблюдаемого поведения аутентификации для клиента.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Таблица `app_settings`
|
||||
`app_settings(key TEXT PRIMARY KEY, value TEXT NOT NULL, created_at, updated_at)` в обоих
|
||||
бэкендах. Ключ `jwt_secret`. Отдельные миграции: PostgreSQL `000010_app_settings`,
|
||||
SQLite `000002_app_settings` (консолидированная 000001 уже применена на существующих БД).
|
||||
|
||||
### D2. Репозиторий `internal/settings`
|
||||
Интерфейс `Repository { Get(ctx,key) (value string, ok bool, err error); Set(ctx,key,value) error; Ensure(ctx,key, generate func() (string,error)) (string, error) }`.
|
||||
Реализации pg и sqlite. `Ensure`: `SELECT` → если есть, вернуть; иначе сгенерировать,
|
||||
`INSERT ... ON CONFLICT(key) DO NOTHING`, затем `SELECT` (единый секрет при гонке).
|
||||
Добавляется в `store.Repos`.
|
||||
|
||||
### D3. Генерация секрета
|
||||
`crypto/rand` 32 байта → base64url (без padding). Значение хранится как строка; в
|
||||
TokenManager и Signer передаётся как есть.
|
||||
|
||||
### D4. Порядок старта и проводка
|
||||
`cmd/server`: миграции → метаданные → `secret := repos.Settings.Ensure("jwt_secret", generate)`
|
||||
→ `tm := auth.NewTokenManager(secret, accessTTL, refreshTTL)` → `storage.Open(ctx, cfg, secret)`
|
||||
(где `URL_SECRET`, если задан, имеет приоритет, иначе используется `secret`) → `app.New`.
|
||||
`app.New` перестаёт строить TokenManager из конфига и принимает готовый `*auth.TokenManager`
|
||||
(или секрет).
|
||||
|
||||
### D5. Конфиг
|
||||
Поле `Auth.JWTSecret` и проверка обязательности удаляются; `JWT_ACCESS_TTL`/`JWT_REFRESH_TTL`
|
||||
остаются. `URL_SECRET` остаётся необязательным. `storage.Open` получает секрет параметром
|
||||
вместо чтения из конфига.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Смена источника секрета у существующих деплоев] → access-токены инвалидируются, но
|
||||
refresh-токены остаются валидными (не подписаны секретом), клиент молча обновляет access;
|
||||
в худшем случае — перелогин. Отмечаем в доках.
|
||||
- [Конкурентный первый старт на общей БД] → `INSERT ... ON CONFLICT DO NOTHING` + повторный
|
||||
`SELECT` (D2).
|
||||
- [Секрет в открытом виде в БД] → БД доверенная; шифрование — вне рамок.
|
||||
- [Тихая смена поведения] → при `URL_SECRET` не задан подпись URL зависит от секрета из БД;
|
||||
явный `URL_SECRET` снимает зависимость.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Добавить таблицу и репозиторий секретов; сид отсутствует — генерируется при старте.
|
||||
2. Переключить `cmd/server`/`app`/`storage` на секрет из метаданных; убрать `JWT_SECRET`.
|
||||
3. Обновить доки и `.env.example`.
|
||||
4. Откат: вернуть чтение `JWT_SECRET` из env (код), данные не меняются.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужна ли операция ротации секрета (сброс значения и инвалидация access-токенов).
|
||||
@@ -0,0 +1,36 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
`JWT_SECRET` сейчас обязателен в окружении: его нужно задавать вручную и одинаково на
|
||||
всех инстансах. Хранение секрета в слое метаданных с автогенерацией убирает обязательный
|
||||
секрет из env и даёт стабильное значение на инсталляцию.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Убрать обязательный `JWT_SECRET` из конфига и окружения.
|
||||
- Добавить таблицу `app_settings` (key/value) в оба бэкенда метаданных (PostgreSQL и
|
||||
SQLite) и репозиторий чтения/создания секрета.
|
||||
- При старте после открытия метаданных читать секрет или генерировать его
|
||||
(`crypto/rand`) и сохранять; использовать для `TokenManager` и как fallback при подписи
|
||||
URL объектов.
|
||||
- `URL_SECRET` остаётся необязательным env-оверрайдом.
|
||||
- Обновить `.env.example`, `project.md`, `README.md`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `app-secrets`: хранение секретов приложения (JWT) в слое метаданных с автогенерацией при первом использовании.
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- Нет изменений требований существующих спек: для клиента поведение аутентификации не меняется. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `backend/internal/config` (убрать `JWTSecret` и его обязательность).
|
||||
- `backend/internal/app`, `backend/cmd/server` (порядок старта: метаданные → секрет →
|
||||
storage/app).
|
||||
- `backend/internal/storage` (fallback-секрет для подписи URL).
|
||||
- Новая settings-репозиция (`internal/settings`), миграции PostgreSQL и SQLite.
|
||||
- Тесты (контрактные, e2e), `.env.example`, `project.md`, `README.md`.
|
||||
- Не затрагивает формат данных клиента.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Хранение секретов приложения в слое метаданных с автогенерацией: единый стабильный
|
||||
секрет на инсталляцию без обязательной настройки окружения.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Хранение секрета приложения
|
||||
Слой метаданных SHALL хранить секрет подписи токенов приложения и SHALL возвращать один
|
||||
и тот же секрет при последующих запусках.
|
||||
|
||||
#### Scenario: Первый запуск
|
||||
- **WHEN** секрет ещё не сохранён
|
||||
- **THEN** система генерирует случайный секрет и сохраняет его в слое метаданных
|
||||
|
||||
#### Scenario: Повторный запуск
|
||||
- **WHEN** секрет уже сохранён
|
||||
- **THEN** система использует сохранённый секрет и не генерирует новый
|
||||
|
||||
### Requirement: Отсутствие ручного секрета
|
||||
Система SHALL работать без секрета, заданного в окружении, и SHALL не требовать его
|
||||
наличия при старте.
|
||||
|
||||
#### Scenario: Секрет не задан в окружении
|
||||
- **WHEN** переменная секрета не задана
|
||||
- **THEN** система запускается, используя секрет из слоя метаданных
|
||||
|
||||
### Requirement: Конкурентный первый запуск
|
||||
При одновременном первом запуске нескольких инстансов на общей базе метаданных система
|
||||
SHALL обеспечивать единый сохранённый секрет для всех инстансов.
|
||||
|
||||
#### Scenario: Гонка первого запуска
|
||||
- **WHEN** два инстанса одновременно обнаруживают отсутствие секрета
|
||||
- **THEN** сохраняется ровно один секрет, и оба инстанса используют его
|
||||
|
||||
### Requirement: Использование секрета для подписи URL
|
||||
При отсутствии отдельного секрета подписи URL система SHALL использовать секрет из слоя
|
||||
метаданных.
|
||||
|
||||
#### Scenario: URL_SECRET не задан
|
||||
- **WHEN** отдельный секрет подписи URL не задан
|
||||
- **THEN** подпись URL объектов использует секрет приложения из слоя метаданных
|
||||
|
||||
#### Scenario: URL_SECRET задан
|
||||
- **WHEN** отдельный секрет подписи URL задан
|
||||
- **THEN** он используется независимо от секрета приложения
|
||||
@@ -0,0 +1,15 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Репозиторий секретов и миграции
|
||||
|
||||
- [x] 1.1 Добавить таблицу `app_settings` миграциями: PostgreSQL `000010_app_settings` и SQLite `000002_app_settings`; проверка — применение на временных БД и идемпотентность.
|
||||
- [x] 1.2 Реализовать `internal/settings` (`Get`/`Set`/`Ensure`) для pg и sqlite и добавить в `store.Repos`; проверка — контрактный тест: `Ensure` идемпотентен и возвращает одно значение.
|
||||
|
||||
## 2. Проводка и конфиг
|
||||
|
||||
- [x] 2.1 Убрать `Auth.JWTSecret` и его обязательность из конфига; `app.New` принимает готовый `*auth.TokenManager`; `storage.Open` принимает секрет-fallback; проверка — `go build ./...`.
|
||||
- [x] 2.2 В `cmd/server` после открытия метаданных получить/сгенерировать секрет и собрать `TokenManager` и storage; проверка — e2e: перезапуск процесса даёт тот же секрет (токен, выпущенный до перезапуска, валиден после).
|
||||
|
||||
## 3. Документация
|
||||
|
||||
- [x] 3.1 Обновить `.env.example`, `project.md`, `README.md`: убрать `JWT_SECRET`, описать автогенерацию секрета в слое метаданных и роль `URL_SECRET`; проверка — документированный запуск без `JWT_SECRET` стартует.
|
||||
+3
-3
@@ -159,11 +159,11 @@ S3_USE_PATH_STYLE=true
|
||||
# Storage backend
|
||||
STORAGE_BACKEND=fs # fs (default, local filesystem) | s3
|
||||
FS_ROOT=./data # root directory for object blobs (fs)
|
||||
URL_SECRET=change_me # signs object URLs; falls back to JWT_SECRET
|
||||
URL_SECRET=change_me # signs object URLs; falls back to the metadata-layer secret
|
||||
OBJECT_URL_TTL=1h # lifetime of signed object URLs
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change_me_long_random
|
||||
# Auth. Token signing secret is generated on first start and stored in the
|
||||
# metadata layer (app_settings) — not configured via env.
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=720h
|
||||
|
||||
|
||||
Reference in New Issue
Block a user