feat: implement shared-album multi-user photo album
Backend (Go/Gin/pgx): env config, pgx pool with retries, Garage S3 client (path-style, region garage), embedded golang-migrate migrations, JWT auth with refresh rotation, albums CRUD, members/roles/invites with no-escalation matrix, photo upload + async imaging/goexif WebP variants, cursor pagination, presigned URLs, likes/comments, SSE hub (album + personal), notifications, share links. Frontend (SvelteKit 2 + Svelte 5 + Tailwind v4): API wrapper with 401 refresh, auth store, all screens, Uploader/PhotoGrid/Lightbox/Comments, SSE client with reconnect + REST catch-up, three themes + system via data-theme. Deploy without Docker: Makefile builds the frontend into backend/static and embeds it (go:embed) into a single statically linked binary; systemd unit and nginx SSE example included. Use gen2brain/webp (-tags nodynamic) instead of cgo kolesa-team/go-webp to keep CGO_ENABLED=0 static builds; design.md/proposal.md updated accordingly.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Postgres struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
}
|
||||
|
||||
func (p Postgres) DSN() string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
p.User, p.Password, p.Host, p.Port, p.Database, p.SSLMode)
|
||||
}
|
||||
|
||||
type S3 struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
UsePathStyle bool
|
||||
PublicURL string
|
||||
PresignTTL time.Duration
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
JWTSecret string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Addr string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type SSE struct {
|
||||
HeartbeatInterval time.Duration
|
||||
ClientBuffer int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Postgres Postgres
|
||||
S3 S3
|
||||
Auth Auth
|
||||
Server Server
|
||||
SSE SSE
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
pgPort, err := strconv.Atoi(getEnv("PG_PORT", "5432"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid PG_PORT: %w", err)
|
||||
}
|
||||
|
||||
accessTTL, err := time.ParseDuration(getEnv("JWT_ACCESS_TTL", "15m"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_ACCESS_TTL: %w", err)
|
||||
}
|
||||
|
||||
refreshTTL, err := time.ParseDuration(getEnv("JWT_REFRESH_TTL", "720h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_REFRESH_TTL: %w", err)
|
||||
}
|
||||
|
||||
presignTTL, err := time.ParseDuration(getEnv("S3_PRESIGN_TTL", "1h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_PRESIGN_TTL: %w", err)
|
||||
}
|
||||
|
||||
heartbeat, err := time.ParseDuration(getEnv("SSE_HEARTBEAT_INTERVAL", "25s"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SSE_HEARTBEAT_INTERVAL: %w", err)
|
||||
}
|
||||
|
||||
bufSize, err := strconv.Atoi(getEnv("SSE_CLIENT_BUFFER", "64"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid SSE_CLIENT_BUFFER: %w", err)
|
||||
}
|
||||
|
||||
pathStyle, err := strconv.ParseBool(getEnv("S3_USE_PATH_STYLE", "true"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid S3_USE_PATH_STYLE: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Postgres: Postgres{
|
||||
Host: getEnv("PG_HOST", "pg.localnet"),
|
||||
Port: pgPort,
|
||||
User: getEnv("PG_USER", "album"),
|
||||
Password: getEnv("PG_PASSWORD", ""),
|
||||
Database: getEnv("PG_DATABASE", "shared_album"),
|
||||
SSLMode: getEnv("PG_SSLMODE", "disable"),
|
||||
},
|
||||
S3: S3{
|
||||
Endpoint: getEnv("S3_ENDPOINT", "http://s3.localnet:3900"),
|
||||
Region: getEnv("S3_REGION", "garage"),
|
||||
AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
||||
SecretKey: getEnv("S3_SECRET_KEY", ""),
|
||||
Bucket: getEnv("S3_BUCKET", "albums"),
|
||||
UsePathStyle: pathStyle,
|
||||
PublicURL: getEnv("S3_PUBLIC_URL", ""),
|
||||
PresignTTL: presignTTL,
|
||||
},
|
||||
Auth: Auth{
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
AccessTTL: accessTTL,
|
||||
RefreshTTL: refreshTTL,
|
||||
},
|
||||
Server: Server{
|
||||
Addr: getEnv("HTTP_ADDR", ":8080"),
|
||||
BaseURL: getEnv("APP_BASE_URL", "http://localhost:8080"),
|
||||
},
|
||||
SSE: SSE{
|
||||
HeartbeatInterval: heartbeat,
|
||||
ClientBuffer: bufSize,
|
||||
},
|
||||
}
|
||||
|
||||
if cfg.Auth.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
if cfg.Postgres.Password == "" {
|
||||
return nil, fmt.Errorf("PG_PASSWORD is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
Reference in New Issue
Block a user