phase 0 complete

This commit is contained in:
2026-08-14 11:29:11 -06:00
parent 4da16c7573
commit 85df9a208e
102 changed files with 25019 additions and 30 deletions
+139
View File
@@ -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
}
+96
View File
@@ -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")
}
}