package main import ( "log" "os" "path/filepath" "strconv" "strings" "time" "github.com/joho/godotenv" ) type Config struct { Addr string DBPath string StorageRoot string AppDomain string AllowedHost string CORSOrigin string CookieSecure bool MaxBodyBytes int64 OCRLangs string GoogleAuthEnabled bool GoogleClientID string GoogleClientSecret string GoogleRedirectURL string GoogleAuthURL string GoogleTokenURL string GoogleUserInfoURL string RateLimitPerMin int AuthRateLimitPerMin int JWTSecret string AccessTTL time.Duration RefreshTTL time.Duration ShareDefaultTTL time.Duration AdminSessionTTL time.Duration AdminLogin string AdminPasswordHash string FTPEnabled bool FTPHost string FTPPort int FTPPublicIP string FTPPassivePorts string FTPSEnabled bool FTPSHost string FTPSPort int FTPSPublicIP string FTPSPassivePorts string FTPSCertFile string FTPSKeyFile string FTPSLEDomain string FTPSLEDir string FTPSExplicit bool FTPSForceTLS bool SFTPEnabled bool SFTPHost string SFTPPort int SFTPHostKeyPath string } func loadConfig() Config { _ = godotenv.Load(".env", "../.env") dbPath := getEnv("DB_PATH", "./app.db") storageRoot := getEnv("STORAGE_ROOT", "./users") if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { log.Fatalf("failed to create db path parent: %v", err) } if err := os.MkdirAll(storageRoot, 0o755); err != nil { log.Fatalf("failed to create storage root: %v", err) } cfg := Config{ Addr: getEnv("ADDR", ":8080"), DBPath: dbPath, StorageRoot: storageRoot, AppDomain: strings.ToLower(strings.TrimSpace(getEnv("APP_DOMAIN", "file.example.com"))), AllowedHost: strings.ToLower(getEnv("ALLOWED_HOST", "")), CORSOrigin: getEnv("CORS_ALLOWED_ORIGIN", ""), CookieSecure: getEnv("COOKIE_SECURE", "false") == "true", MaxBodyBytes: int64(getEnvInt("MAX_BODY_MB", 8)) * 1024 * 1024, OCRLangs: normalizeOCRLangs(getEnv("OCR_LANGS", "eng+osd")), GoogleAuthEnabled: getEnv("GOOGLE_AUTH_ENABLED", "false") == "true", GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", ""), GoogleAuthURL: getEnv("GOOGLE_AUTH_URL", "https://accounts.google.com/o/oauth2/v2/auth"), GoogleTokenURL: getEnv("GOOGLE_TOKEN_URL", "https://oauth2.googleapis.com/token"), GoogleUserInfoURL: getEnv("GOOGLE_USERINFO_URL", "https://openidconnect.googleapis.com/v1/userinfo"), RateLimitPerMin: getEnvInt("RATE_LIMIT_PER_MIN", 240), AuthRateLimitPerMin: getEnvInt("AUTH_RATE_LIMIT_PER_MIN", 30), JWTSecret: getEnv("JWT_SECRET", "dev-change-me-immediately"), AccessTTL: 15 * time.Minute, RefreshTTL: 30 * 24 * time.Hour, ShareDefaultTTL: 24 * time.Hour, AdminSessionTTL: 12 * time.Hour, AdminLogin: getEnv("ADMIN_LOGIN", "admin"), AdminPasswordHash: getEnv("ADMIN_PASSWORD_HASH", ""), FTPEnabled: getEnv("FTP_ENABLED", "false") == "true", FTPHost: getEnv("FTP_HOST", "0.0.0.0"), FTPPort: getEnvInt("FTP_PORT", 2121), FTPPublicIP: getEnv("FTP_PUBLIC_IP", ""), FTPPassivePorts: getEnv("FTP_PASSIVE_PORTS", ""), FTPSEnabled: getEnv("FTPS_ENABLED", "false") == "true", FTPSHost: getEnv("FTPS_HOST", "0.0.0.0"), FTPSPort: getEnvInt("FTPS_PORT", 2990), FTPSPublicIP: getEnv("FTPS_PUBLIC_IP", ""), FTPSPassivePorts: getEnv("FTPS_PASSIVE_PORTS", ""), FTPSCertFile: getEnv("FTPS_CERT_FILE", ""), FTPSKeyFile: getEnv("FTPS_KEY_FILE", ""), FTPSLEDomain: strings.ToLower(strings.TrimSpace(getEnv("FTPS_LETSENCRYPT_DOMAIN", ""))), FTPSLEDir: getEnv("FTPS_LETSENCRYPT_DIR", "/etc/letsencrypt/live"), FTPSExplicit: getEnv("FTPS_EXPLICIT", "true") != "false", FTPSForceTLS: getEnv("FTPS_FORCE_TLS", "true") != "false", SFTPEnabled: getEnv("SFTP_ENABLED", "false") == "true", SFTPHost: getEnv("SFTP_HOST", "0.0.0.0"), SFTPPort: getEnvInt("SFTP_PORT", 2022), SFTPHostKeyPath: getEnv("SFTP_HOST_KEY_PATH", "./sftp_host_ed25519"), } if cfg.AllowedHost == "" { cfg.AllowedHost = cfg.AppDomain } if cfg.CORSOrigin == "" { cfg.CORSOrigin = "https://" + cfg.AppDomain } if cfg.JWTSecret == "dev-change-me-immediately" { log.Println("warning: JWT_SECRET is using default development value") } if strings.TrimSpace(cfg.AdminPasswordHash) == "" { log.Fatal("ADMIN_PASSWORD_HASH is required. Generate one with: go run . hash-admin ") } if cfg.GoogleAuthEnabled { if strings.TrimSpace(cfg.GoogleClientID) == "" || strings.TrimSpace(cfg.GoogleClientSecret) == "" { log.Fatal("GOOGLE_AUTH_ENABLED=true requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET") } } if cfg.FTPEnabled { if cfg.FTPPort < 1 || cfg.FTPPort > 65535 { log.Fatal("FTP_PORT must be in range 1..65535") } } if cfg.FTPSEnabled { if cfg.FTPSPort < 1 || cfg.FTPSPort > 65535 { log.Fatal("FTPS_PORT must be in range 1..65535") } applyFTPSLetsEncryptDefaults(&cfg) if strings.TrimSpace(cfg.FTPSCertFile) == "" || strings.TrimSpace(cfg.FTPSKeyFile) == "" { log.Fatal("FTPS_ENABLED=true requires FTPS_CERT_FILE/FTPS_KEY_FILE or FTPS_LETSENCRYPT_DOMAIN") } if _, err := os.Stat(cfg.FTPSCertFile); err != nil { log.Fatalf("FTPS_CERT_FILE is invalid: %v", err) } if _, err := os.Stat(cfg.FTPSKeyFile); err != nil { log.Fatalf("FTPS_KEY_FILE is invalid: %v", err) } } if cfg.FTPEnabled && cfg.FTPSEnabled { if cfg.FTPPort == cfg.FTPSPort && strings.EqualFold(cfg.FTPHost, cfg.FTPSHost) { log.Fatal("FTP and FTPS cannot share the same host:port") } } if cfg.SFTPEnabled { if cfg.SFTPPort < 1 || cfg.SFTPPort > 65535 { log.Fatal("SFTP_PORT must be in range 1..65535") } } return cfg } func normalizeOCRLangs(v string) string { fields := strings.FieldsFunc(strings.TrimSpace(strings.ToLower(v)), func(r rune) bool { return r == '+' || r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t' || r == '\n' || r == '\r' }) if len(fields) == 0 { return "eng+osd" } seen := make(map[string]struct{}, len(fields)) out := make([]string, 0, len(fields)) for _, field := range fields { field = strings.TrimSpace(field) if field == "" { continue } valid := true for _, ch := range field { if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '_' { valid = false break } } if !valid { continue } if _, ok := seen[field]; ok { continue } seen[field] = struct{}{} out = append(out, field) } if len(out) == 0 { return "eng+osd" } return strings.Join(out, "+") } func applyFTPSLetsEncryptDefaults(cfg *Config) { if cfg == nil { return } if strings.TrimSpace(cfg.FTPSLEDomain) == "" { return } base := strings.TrimSpace(cfg.FTPSLEDir) if base == "" { base = "/etc/letsencrypt/live" } domainDir := filepath.Join(base, cfg.FTPSLEDomain) if strings.TrimSpace(cfg.FTPSCertFile) == "" { cfg.FTPSCertFile = filepath.Join(domainDir, "fullchain.pem") } if strings.TrimSpace(cfg.FTPSKeyFile) == "" { cfg.FTPSKeyFile = filepath.Join(domainDir, "privkey.pem") } } func getEnv(key, fallback string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } return v } func getEnvInt(key string, fallback int) int { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback } n, err := strconv.Atoi(v) if err != nil { return fallback } return n }