phase 0 complete
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// Package config loads and validates SoloPM's environment-variable configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds every environment-driven setting for the SoloPM server.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
HTTPPort string
|
||||
BaseURL string
|
||||
SessionSecret string
|
||||
UploadDir string
|
||||
MaxUploadMB int
|
||||
|
||||
MeiliURL string
|
||||
MeiliKey string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
|
||||
GiteaURL string
|
||||
GiteaClientID string
|
||||
GiteaClientSecret string
|
||||
|
||||
CullAfterDays int
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// SearchEnabled reports whether Meilisearch is configured.
|
||||
func (c *Config) SearchEnabled() bool {
|
||||
return c.MeiliURL != "" && c.MeiliKey != ""
|
||||
}
|
||||
|
||||
// EmailEnabled reports whether SMTP is configured.
|
||||
func (c *Config) EmailEnabled() bool {
|
||||
return c.SMTPHost != ""
|
||||
}
|
||||
|
||||
// GitHubOAuthEnabled reports whether GitHub OAuth is configured.
|
||||
func (c *Config) GitHubOAuthEnabled() bool {
|
||||
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
|
||||
}
|
||||
|
||||
// GiteaOAuthEnabled reports whether Gitea OAuth is configured.
|
||||
func (c *Config) GiteaOAuthEnabled() bool {
|
||||
return c.GiteaURL != "" && c.GiteaClientID != "" && c.GiteaClientSecret != ""
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults and
|
||||
// failing fast (returning an error) if a required variable is missing or
|
||||
// malformed.
|
||||
func Load() (*Config, error) {
|
||||
var missing []string
|
||||
required := func(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
c := &Config{
|
||||
DatabaseURL: required("DATABASE_URL"),
|
||||
HTTPPort: getDefault("HTTP_PORT", "8080"),
|
||||
BaseURL: required("BASE_URL"),
|
||||
SessionSecret: required("SESSION_SECRET"),
|
||||
UploadDir: getDefault("UPLOAD_DIR", "./uploads"),
|
||||
|
||||
MeiliURL: os.Getenv("MEILI_URL"),
|
||||
MeiliKey: os.Getenv("MEILI_KEY"),
|
||||
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: os.Getenv("SMTP_PORT"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPass: os.Getenv("SMTP_PASS"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
|
||||
GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||
GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||
|
||||
GiteaURL: os.Getenv("GITEA_URL"),
|
||||
GiteaClientID: os.Getenv("GITEA_CLIENT_ID"),
|
||||
GiteaClientSecret: os.Getenv("GITEA_CLIENT_SECRET"),
|
||||
|
||||
LogLevel: getDefault("LOG_LEVEL", "info"),
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return nil, fmt.Errorf("config: missing required environment variable(s): %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return nil, fmt.Errorf("config: SESSION_SECRET must be at least 32 bytes, got %d", len(c.SessionSecret))
|
||||
}
|
||||
|
||||
maxUploadMB, err := getIntDefault("MAX_UPLOAD_MB", 25)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.MaxUploadMB = maxUploadMB
|
||||
|
||||
cullAfterDays, err := getIntDefault("CULL_AFTER_DAYS", 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.CullAfterDays = cullAfterDays
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func getDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func getIntDefault(key string, def int) (int, error) {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("config: %s must be an integer, got %q", key, v)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func clearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
keys := []string{
|
||||
"DATABASE_URL", "HTTP_PORT", "BASE_URL", "SESSION_SECRET", "UPLOAD_DIR",
|
||||
"MAX_UPLOAD_MB", "MEILI_URL", "MEILI_KEY", "SMTP_HOST", "SMTP_PORT",
|
||||
"SMTP_USER", "SMTP_PASS", "SMTP_FROM", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
|
||||
"GITEA_URL", "GITEA_CLIENT_ID", "GITEA_CLIENT_SECRET", "CULL_AFTER_DAYS", "LOG_LEVEL",
|
||||
}
|
||||
for _, k := range keys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
}
|
||||
|
||||
func validRequired(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("DATABASE_URL", "postgres://localhost/solopm")
|
||||
t.Setenv("BASE_URL", "http://localhost:8080")
|
||||
t.Setenv("SESSION_SECRET", "01234567890123456789012345678901")
|
||||
}
|
||||
|
||||
func TestLoad_MissingRequired(t *testing.T) {
|
||||
clearEnv(t)
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required vars, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_ShortSessionSecret(t *testing.T) {
|
||||
clearEnv(t)
|
||||
t.Setenv("DATABASE_URL", "postgres://localhost/solopm")
|
||||
t.Setenv("BASE_URL", "http://localhost:8080")
|
||||
t.Setenv("SESSION_SECRET", "tooshort")
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for short SESSION_SECRET, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if c.HTTPPort != "8080" {
|
||||
t.Errorf("HTTPPort = %q, want 8080", c.HTTPPort)
|
||||
}
|
||||
if c.UploadDir != "./uploads" {
|
||||
t.Errorf("UploadDir = %q, want ./uploads", c.UploadDir)
|
||||
}
|
||||
if c.MaxUploadMB != 25 {
|
||||
t.Errorf("MaxUploadMB = %d, want 25", c.MaxUploadMB)
|
||||
}
|
||||
if c.CullAfterDays != 30 {
|
||||
t.Errorf("CullAfterDays = %d, want 30", c.CullAfterDays)
|
||||
}
|
||||
if c.LogLevel != "info" {
|
||||
t.Errorf("LogLevel = %q, want info", c.LogLevel)
|
||||
}
|
||||
if c.SearchEnabled() {
|
||||
t.Error("SearchEnabled() = true, want false when MEILI_URL/KEY unset")
|
||||
}
|
||||
if c.EmailEnabled() {
|
||||
t.Error("EmailEnabled() = true, want false when SMTP_HOST unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SearchEnabled(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
t.Setenv("MEILI_URL", "http://meilisearch:7700")
|
||||
t.Setenv("MEILI_KEY", "key")
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !c.SearchEnabled() {
|
||||
t.Error("SearchEnabled() = false, want true when MEILI_URL/KEY set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_InvalidInt(t *testing.T) {
|
||||
clearEnv(t)
|
||||
validRequired(t)
|
||||
t.Setenv("MAX_UPLOAD_MB", "not-a-number")
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer MAX_UPLOAD_MB, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user