Files
Michilis 498d7d8a7d Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
2026-08-05 05:41:12 +00:00

158 lines
4.5 KiB
Go

// Package config loads photo-api configuration from the environment,
// following the backend's convention of a per-service .env file with
// ad-hoc defaults (backend/src/index.ts uses dotenv the same way).
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
type Config struct {
Port int
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
DatabaseURL string
// ViewTokenSecret signs the hour-bucketed gallery view tokens
// (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy
// JWT_SECRET during the Better Auth migration.
ViewTokenSecret string
// StorageBackend selects the active backend: "auto" (S3 when it is
// configured, local otherwise), "local" or "s3". Explicit values let both
// backends stay configured — required to run `photo-api sync`, and the
// one-line switch after a sync.
StorageBackend string
StoragePath string
S3Endpoint string
S3Region string
S3Bucket string
S3AccessKeyID string
S3SecretKey string
S3ForcePathStyle bool
MaxUploadMB int
WorkerConcurrency int
FrontendURL string
HeicConverter string // optional explicit converter command; autodetected when empty
}
// S3Configured reports whether the S3 credentials are present at all,
// mirroring backend/src/lib/storage.ts: both S3_ENDPOINT and S3_BUCKET set.
// Kept separate from S3Enabled so a configured-but-inactive S3 backend can
// still be reached by `photo-api sync`.
func (c Config) S3Configured() bool {
return c.S3Endpoint != "" && c.S3Bucket != ""
}
// S3Enabled reports whether S3 is the backend serving requests.
func (c Config) S3Enabled() bool {
switch c.StorageBackend {
case "s3":
return true
case "local":
return false
default: // auto
return c.S3Configured()
}
}
func Load() (Config, error) {
loadDotenv(".env")
cfg := Config{
Port: envInt("PORT", 3003),
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""),
ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")),
StorageBackend: strings.ToLower(env("STORAGE_BACKEND", "auto")),
StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"),
S3Bucket: env("S3_BUCKET", ""),
S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""),
S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""),
S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true),
MaxUploadMB: envInt("MAX_UPLOAD_MB", 50),
WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2),
FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"),
HeicConverter: env("HEIC_CONVERTER", ""),
}
if cfg.DBType != "postgres" && cfg.DBType != "sqlite" {
return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType)
}
if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required")
}
if cfg.ViewTokenSecret == "" {
return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)")
}
switch cfg.StorageBackend {
case "auto", "local", "s3":
default:
return cfg, fmt.Errorf("STORAGE_BACKEND must be auto, local or s3, got %q", cfg.StorageBackend)
}
if cfg.StorageBackend == "s3" && !cfg.S3Configured() {
return cfg, fmt.Errorf("STORAGE_BACKEND=s3 requires S3_ENDPOINT and S3_BUCKET")
}
return cfg, nil
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
// loadDotenv is a minimal KEY=VALUE loader (comments and blank lines
// ignored, optional surrounding quotes stripped, existing env wins).
func loadDotenv(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] {
val = val[1 : len(val)-1]
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
}