feat(backend): bootstrap Go module, config, and data plane
Introduce configuration validation, Postgres migrations, Redis/storage adapters, and the seed sample video.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
JWTSecret string
|
||||
PublicURL string
|
||||
FrontendOrigins []string
|
||||
TrustedProxyCIDRs []*net.IPNet
|
||||
MetricsToken string
|
||||
StorageDriver string
|
||||
StorageDir string
|
||||
StorageEndpoint string
|
||||
StorageRegion string
|
||||
StorageAccess string
|
||||
StorageSecret string
|
||||
StorageBucket string
|
||||
HeartRegenHours int
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration // remember-me / long-lived refresh
|
||||
RefreshTokenTTLSession time.Duration // short-lived refresh when rememberMe is false
|
||||
MediaURLTTL time.Duration
|
||||
DevMode bool
|
||||
// CaptchaExposeAnswer returns plaintext captcha answers in GET /api/auth/captcha.
|
||||
// Must never be enabled outside local tests (requires DevMode as well).
|
||||
CaptchaExposeAnswer bool
|
||||
SeedCurriculum bool
|
||||
OTELEndpoint string
|
||||
OTELServiceName string
|
||||
DonationEnabled bool
|
||||
DonationNote string
|
||||
PhysicalCertEnabled bool
|
||||
PhysicalCertPriceIRR int
|
||||
PhysicalCertWindowDays int
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// defaultTrustedProxyCIDRs covers loopback and typical Docker/private networks.
|
||||
const defaultTrustedProxyCIDRs = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
|
||||
|
||||
func parseCIDRs(raw string) ([]*net.IPNet, error) {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]*net.IPNet, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
_, network, err := net.ParseCIDR(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, network)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
jwtSecret := getEnv("JWT_SECRET", "")
|
||||
if jwtSecret == "" {
|
||||
jwtSecret = developmentSecret()
|
||||
}
|
||||
// Fail closed: production-safe default unless explicitly enabled.
|
||||
devMode := getEnv("DEV_MODE", "false") == "true"
|
||||
origins := parseOrigins(getEnv("FRONTEND_ORIGINS", ""))
|
||||
if len(origins) == 0 {
|
||||
origins = []string{
|
||||
"https://localhost", "https://127.0.0.1",
|
||||
"http://localhost:3000", "http://127.0.0.1:3000",
|
||||
"http://localhost:3001", "http://127.0.0.1:3001",
|
||||
"http://localhost", "http://127.0.0.1",
|
||||
}
|
||||
}
|
||||
seedCurriculum := getEnv("SEED_CURRICULUM", "") == "true"
|
||||
if getEnv("SEED_CURRICULUM", "") == "" && devMode {
|
||||
seedCurriculum = true
|
||||
}
|
||||
cidrs, err := parseCIDRs(getEnv("TRUSTED_PROXY_CIDRS", defaultTrustedProxyCIDRs))
|
||||
if err != nil {
|
||||
// Fall back to defaults rather than refusing to boot on a typo in optional env.
|
||||
cidrs, _ = parseCIDRs(defaultTrustedProxyCIDRs)
|
||||
}
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DatabaseURL: resolveDatabaseURL(),
|
||||
RedisURL: resolveRedisURL(),
|
||||
JWTSecret: jwtSecret,
|
||||
PublicURL: strings.TrimRight(getEnv("PUBLIC_URL", "https://localhost"), "/"),
|
||||
FrontendOrigins: origins,
|
||||
TrustedProxyCIDRs: cidrs,
|
||||
MetricsToken: strings.TrimSpace(getEnv("METRICS_TOKEN", "")),
|
||||
StorageDriver: getEnv("STORAGE_DRIVER", "s3"),
|
||||
StorageDir: getEnv("STORAGE_DIR", "media"),
|
||||
StorageEndpoint: getEnv("STORAGE_ENDPOINT", "http://rustfs:9000"),
|
||||
StorageRegion: getEnv("STORAGE_REGION", "us-east-1"),
|
||||
StorageAccess: getEnv("STORAGE_ACCESS_KEY", ""),
|
||||
StorageSecret: getEnv("STORAGE_SECRET_KEY", ""),
|
||||
StorageBucket: getEnv("STORAGE_BUCKET", "pargar"),
|
||||
HeartRegenHours: getEnvInt("HEART_REGEN_HOURS", 4),
|
||||
AccessTokenTTL: 15 * time.Minute,
|
||||
RefreshTokenTTL: 30 * 24 * time.Hour,
|
||||
RefreshTokenTTLSession: 24 * time.Hour,
|
||||
MediaURLTTL: 2 * time.Hour,
|
||||
DevMode: devMode,
|
||||
CaptchaExposeAnswer: devMode && getEnv("CAPTCHA_EXPOSE_ANSWER", "") == "true",
|
||||
SeedCurriculum: seedCurriculum,
|
||||
OTELEndpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
|
||||
OTELServiceName: getEnv("OTEL_SERVICE_NAME", "pargar-api"),
|
||||
DonationEnabled: getEnv("DONATION_ENABLED", "true") == "true",
|
||||
DonationNote: getEnv("DONATION_NOTE", "اگر مایلید از مسیر حمایت کنید، وارد شوید و در گفتگو با تیم هماهنگ کنید — جزئیات فقط خصوصی رد و بدل میشود."),
|
||||
PhysicalCertEnabled: getEnv("PHYSICAL_CERT_ENABLED", "false") == "true",
|
||||
PhysicalCertPriceIRR: getEnvInt("PHYSICAL_CERT_PRICE_IRR", 2_500_000),
|
||||
PhysicalCertWindowDays: getEnvInt("PHYSICAL_CERT_WINDOW_DAYS", 30),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDatabaseURL prefers DATABASE_URL; otherwise builds from POSTGRES_* with proper encoding.
|
||||
func resolveDatabaseURL() string {
|
||||
if u := strings.TrimSpace(os.Getenv("DATABASE_URL")); u != "" {
|
||||
return u
|
||||
}
|
||||
pass := os.Getenv("POSTGRES_PASSWORD")
|
||||
host := getEnv("POSTGRES_HOST", "")
|
||||
if pass == "" || host == "" {
|
||||
return "postgres://pargar:local-dev-only-password@localhost:5432/pargar?sslmode=disable"
|
||||
}
|
||||
user := getEnv("POSTGRES_USER", "pargar")
|
||||
db := getEnv("POSTGRES_DB", "pargar")
|
||||
ssl := getEnv("POSTGRES_SSLMODE", "disable")
|
||||
port := getEnv("POSTGRES_PORT", "5432")
|
||||
u := &url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(user, pass),
|
||||
Host: net.JoinHostPort(host, port),
|
||||
Path: "/" + db,
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("sslmode", ssl)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// resolveRedisURL prefers REDIS_URL; otherwise builds from REDIS_HOST + REDIS_PASSWORD.
|
||||
func resolveRedisURL() string {
|
||||
if u := strings.TrimSpace(os.Getenv("REDIS_URL")); u != "" {
|
||||
return u
|
||||
}
|
||||
host := getEnv("REDIS_HOST", "")
|
||||
if host == "" {
|
||||
return "redis://localhost:6379"
|
||||
}
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
pass := os.Getenv("REDIS_PASSWORD")
|
||||
u := &url.URL{
|
||||
Scheme: "redis",
|
||||
Host: net.JoinHostPort(host, port),
|
||||
}
|
||||
if pass != "" {
|
||||
u.User = url.UserPassword("", pass)
|
||||
}
|
||||
if db := strings.TrimSpace(os.Getenv("REDIS_DB")); db != "" {
|
||||
u.Path = "/" + db
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func parseOrigins(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, strings.TrimRight(p, "/"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var insecureSecretMarkers = []string{
|
||||
"change-me",
|
||||
"local-dev-only",
|
||||
"insecure-default",
|
||||
"password",
|
||||
"secret",
|
||||
"admin",
|
||||
}
|
||||
|
||||
func isInsecureSecret(v string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
// Exact trivial values.
|
||||
for _, m := range insecureSecretMarkers {
|
||||
if s == m || s == m+"123" || s == m+"!" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Known chart / compose placeholders.
|
||||
placeholders := []string{"change-me", "local-dev-only", "insecure-default"}
|
||||
for _, p := range placeholders {
|
||||
if strings.Contains(s, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.DevMode {
|
||||
return nil
|
||||
}
|
||||
jwt := strings.TrimSpace(os.Getenv("JWT_SECRET"))
|
||||
if len(jwt) < 32 {
|
||||
return errors.New("JWT_SECRET must be at least 32 characters when DEV_MODE is false")
|
||||
}
|
||||
if isInsecureSecret(jwt) {
|
||||
return errors.New("JWT_SECRET looks like a placeholder; set a strong random value")
|
||||
}
|
||||
adminPass := getEnv("ADMIN_PASSWORD", "")
|
||||
if adminPass == "" {
|
||||
return errors.New("ADMIN_PASSWORD must be set when DEV_MODE is false")
|
||||
}
|
||||
if isInsecureSecret(adminPass) || len(adminPass) < 12 {
|
||||
return errors.New("ADMIN_PASSWORD looks weak or like a placeholder; set a strong value")
|
||||
}
|
||||
if c.StorageDriver == "s3" {
|
||||
if c.StorageAccess == "" || c.StorageSecret == "" {
|
||||
return errors.New("STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY must be set when DEV_MODE is false")
|
||||
}
|
||||
if isInsecureSecret(c.StorageSecret) {
|
||||
return errors.New("STORAGE_SECRET_KEY looks like a placeholder; set a strong random value")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) AllowedOrigins() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(o string) {
|
||||
o = strings.TrimRight(strings.TrimSpace(o), "/")
|
||||
if o == "" || seen[o] {
|
||||
return
|
||||
}
|
||||
seen[o] = true
|
||||
out = append(out, o)
|
||||
}
|
||||
for _, o := range c.FrontendOrigins {
|
||||
add(o)
|
||||
}
|
||||
add(c.PublicURL)
|
||||
return out
|
||||
}
|
||||
|
||||
// IsTrustedProxy reports whether ip is allowed to supply X-Real-IP / X-Forwarded-For.
|
||||
func (c *Config) IsTrustedProxy(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, network := range c.TrustedProxyCIDRs {
|
||||
if network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CookieSecure reports whether session cookies should set the Secure flag.
|
||||
func (c *Config) CookieSecure() bool {
|
||||
return strings.HasPrefix(strings.ToLower(c.PublicURL), "https://")
|
||||
}
|
||||
|
||||
func developmentSecret() string {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
panic("could not generate development JWT secret")
|
||||
}
|
||||
return hex.EncodeToString(raw[:])
|
||||
}
|
||||
|
||||
// MustValidate is a helper for tests asserting Validate messages.
|
||||
func FormatValidateError(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", err)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateRejectsPlaceholderSecrets(t *testing.T) {
|
||||
t.Setenv("DEV_MODE", "false")
|
||||
t.Setenv("JWT_SECRET", "change-me-to-at-least-32-characters-long")
|
||||
t.Setenv("ADMIN_PASSWORD", "change-me-strong-admin-password")
|
||||
t.Setenv("STORAGE_ACCESS_KEY", "ak")
|
||||
t.Setenv("STORAGE_SECRET_KEY", "change-me-long-rustfs-secret")
|
||||
|
||||
cfg := Load()
|
||||
cfg.DevMode = false
|
||||
cfg.StorageDriver = "s3"
|
||||
cfg.StorageAccess = "ak"
|
||||
cfg.StorageSecret = "change-me-long-rustfs-secret"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected Validate to reject placeholder JWT")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsStrongSecrets(t *testing.T) {
|
||||
t.Setenv("DEV_MODE", "false")
|
||||
jwt := strings.Repeat("a1B2c3D4", 6) // 48 chars, no placeholder markers
|
||||
t.Setenv("JWT_SECRET", jwt)
|
||||
t.Setenv("ADMIN_PASSWORD", "Tr0ub4dor&3-not-default")
|
||||
cfg := Load()
|
||||
cfg.DevMode = false
|
||||
cfg.StorageDriver = "s3"
|
||||
cfg.StorageAccess = "AKIAEXAMPLE"
|
||||
cfg.StorageSecret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDatabaseURLEncodesPassword(t *testing.T) {
|
||||
os.Unsetenv("DATABASE_URL")
|
||||
t.Setenv("POSTGRES_USER", "pargar")
|
||||
t.Setenv("POSTGRES_PASSWORD", "p@ss:word/with%chars")
|
||||
t.Setenv("POSTGRES_HOST", "db.internal")
|
||||
t.Setenv("POSTGRES_DB", "pargar")
|
||||
t.Setenv("POSTGRES_SSLMODE", "disable")
|
||||
u := resolveDatabaseURL()
|
||||
if !strings.Contains(u, "p%40ss%3Aword%2Fwith%25chars") {
|
||||
t.Fatalf("password not encoded in URL: %s", u)
|
||||
}
|
||||
if !strings.Contains(u, "@db.internal:") {
|
||||
t.Fatalf("host missing: %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRedisURLEncodesPassword(t *testing.T) {
|
||||
os.Unsetenv("REDIS_URL")
|
||||
t.Setenv("REDIS_HOST", "redis.internal")
|
||||
t.Setenv("REDIS_PASSWORD", "r@dis:pass")
|
||||
u := resolveRedisURL()
|
||||
if !strings.Contains(u, "%40") || !strings.Contains(u, "%3A") {
|
||||
t.Fatalf("redis password not encoded: %s", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInsecureSecret(t *testing.T) {
|
||||
if !isInsecureSecret("change-me-postgres") {
|
||||
t.Fatal("expected change-me to be insecure")
|
||||
}
|
||||
if isInsecureSecret(strings.Repeat("xY9k", 12)) {
|
||||
t.Fatal("random-looking secret should be ok")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user