98de298e5b
Opening an original by URL (?access_token=) baked in the 15-minute access token, so a long video opened in a new tab stopped streaming once that token expired mid-playback: the access token can't be refreshed in an already-opened tab, and its next Range request 401'd. Add a content token: a signed, single-file capability (typ=content, fid claim) with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no session id, so it survives refresh rotation and outlives the short access TTL. POST /files/:id/content-token mints one after the same view-ACL check content serving does; GET /files/:id/content now runs under content-aware auth that accepts either a normal access token or a content token scoped to that file. View permission is still enforced against the token's user, so the token only changes when a file may be read by URL, never which files. It's a bearer capability for that one file until expiry, hence the bounded, configurable TTL. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
5.2 KiB
Go
179 lines
5.2 KiB
Go
package config
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/joho/godotenv"
|
||
)
|
||
|
||
// Config holds all application configuration loaded from environment variables.
|
||
type Config struct {
|
||
// Server
|
||
ListenAddr string
|
||
JWTSecret string
|
||
JWTAccessTTL time.Duration
|
||
JWTRefreshTTL time.Duration
|
||
// ContentTokenTTL is how long a content token stays valid. The token is a
|
||
// single-file capability used to open or stream an original by URL (e.g. a
|
||
// long video in a new tab); it is deliberately longer-lived than the access
|
||
// token and independent of the session, so playback survives access-token
|
||
// expiry and refresh rotation. Keep it only as long as a viewing session
|
||
// plausibly lasts — it is a bearer credential for that one file until expiry.
|
||
ContentTokenTTL time.Duration
|
||
// TrustedProxies lists the reverse-proxy hops (CIDRs or IPs) whose
|
||
// X-Forwarded-For header is trusted. The auth rate limiter keys on the
|
||
// client IP, so this must match the proxy in front of the app — otherwise
|
||
// every request appears to come from the proxy (one shared bucket) or a
|
||
// direct caller could forge the header. Default covers loopback and the
|
||
// Docker bridge ranges a host reverse proxy reaches the container through.
|
||
TrustedProxies []string
|
||
|
||
// Initial admin bootstrap (applied on startup if the user does not exist)
|
||
AdminUsername string
|
||
AdminPassword string
|
||
|
||
// Database
|
||
DatabaseURL string
|
||
|
||
// Storage
|
||
FilesPath string
|
||
ThumbsCachePath string
|
||
MaxUploadBytes int64 // reject uploads larger than this (bytes)
|
||
|
||
// Thumbnails
|
||
ThumbWidth int
|
||
ThumbHeight int
|
||
PreviewWidth int
|
||
PreviewHeight int
|
||
// ThumbMaxPixels caps the pixel count of a source image decoded in-process by
|
||
// the pure-Go fallback (a decompression-bomb guard and memory bound); larger
|
||
// images then get a placeholder. It does not apply when vipsthumbnail is
|
||
// installed, which shrinks on load regardless of source size.
|
||
ThumbMaxPixels int
|
||
// ThumbConcurrency bounds how many thumbnails/previews are generated at once,
|
||
// so a burst of large images can't saturate every core or exhaust RAM. 0 =
|
||
// auto (half the available CPUs).
|
||
ThumbConcurrency int
|
||
|
||
// Import
|
||
ImportPath string
|
||
|
||
// Static SPA. When set, the server serves the built frontend (and falls
|
||
// back to index.html for client routes) on the same port as the API. Empty
|
||
// in local development, where the Vite dev server serves the UI separately.
|
||
StaticDir string
|
||
}
|
||
|
||
// Load reads a .env file (if present) then loads all configuration from
|
||
// environment variables. Returns an error listing every missing or invalid var.
|
||
func Load() (*Config, error) {
|
||
// Non-fatal: .env may not exist in production.
|
||
_ = godotenv.Load()
|
||
|
||
var errs []error
|
||
|
||
requireStr := func(key string) string {
|
||
v := os.Getenv(key)
|
||
if v == "" {
|
||
errs = append(errs, fmt.Errorf("%s is required", key))
|
||
}
|
||
return v
|
||
}
|
||
|
||
defaultStr := func(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
parseDuration := func(key, def string) time.Duration {
|
||
raw := defaultStr(key, def)
|
||
d, err := time.ParseDuration(raw)
|
||
if err != nil {
|
||
errs = append(errs, fmt.Errorf("%s: invalid duration %q: %w", key, raw, err))
|
||
return 0
|
||
}
|
||
return d
|
||
}
|
||
|
||
parseInt := func(key string, def int) int {
|
||
raw := os.Getenv(key)
|
||
if raw == "" {
|
||
return def
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil {
|
||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
parseCSV := func(key, def string) []string {
|
||
raw := defaultStr(key, def)
|
||
parts := strings.Split(raw, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
parseInt64 := func(key string, def int64) int64 {
|
||
raw := os.Getenv(key)
|
||
if raw == "" {
|
||
return def
|
||
}
|
||
n, err := strconv.ParseInt(raw, 10, 64)
|
||
if err != nil {
|
||
errs = append(errs, fmt.Errorf("%s: invalid integer %q: %w", key, raw, err))
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
cfg := &Config{
|
||
ListenAddr: defaultStr("LISTEN_ADDR", ":42776"),
|
||
JWTSecret: requireStr("JWT_SECRET"),
|
||
JWTAccessTTL: parseDuration("JWT_ACCESS_TTL", "15m"),
|
||
JWTRefreshTTL: parseDuration("JWT_REFRESH_TTL", "720h"),
|
||
|
||
ContentTokenTTL: parseDuration("CONTENT_TOKEN_TTL", "6h"),
|
||
|
||
TrustedProxies: parseCSV("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,172.16.0.0/12"),
|
||
|
||
AdminUsername: defaultStr("ADMIN_USERNAME", "admin"),
|
||
AdminPassword: requireStr("ADMIN_PASSWORD"),
|
||
|
||
DatabaseURL: requireStr("DATABASE_URL"),
|
||
|
||
FilesPath: requireStr("FILES_PATH"),
|
||
ThumbsCachePath: requireStr("THUMBS_CACHE_PATH"),
|
||
MaxUploadBytes: parseInt64("MAX_UPLOAD_BYTES", 500<<20), // 500 MiB
|
||
|
||
ThumbWidth: parseInt("THUMB_WIDTH", 160),
|
||
ThumbHeight: parseInt("THUMB_HEIGHT", 160),
|
||
PreviewWidth: parseInt("PREVIEW_WIDTH", 1920),
|
||
PreviewHeight: parseInt("PREVIEW_HEIGHT", 1080),
|
||
ThumbMaxPixels: parseInt("THUMB_MAX_PIXELS", 300_000_000), // ~300 Mpx (e.g. 13000×17000)
|
||
ThumbConcurrency: parseInt("THUMB_CONCURRENCY", 0), // 0 = auto
|
||
|
||
ImportPath: requireStr("IMPORT_PATH"),
|
||
|
||
StaticDir: defaultStr("STATIC_DIR", ""),
|
||
}
|
||
|
||
if len(errs) > 0 {
|
||
return nil, errors.Join(errs...)
|
||
}
|
||
return cfg, nil
|
||
}
|