first commit
This commit is contained in:
171
internal/config/config.go
Normal file
171
internal/config/config.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type LightningConfig struct {
|
||||
Enabled bool
|
||||
LNbitsURL string
|
||||
LNbitsInvoiceKey string
|
||||
PriceYearlySats int64
|
||||
PriceLifetimeSats int64
|
||||
InvoiceExpiryMins int
|
||||
}
|
||||
|
||||
type NostrConfig struct {
|
||||
Relays []string
|
||||
UsernameSyncEnabled bool
|
||||
SyncIntervalMins int
|
||||
}
|
||||
|
||||
type DMConfig struct {
|
||||
Enabled bool
|
||||
Nsec string
|
||||
Kind int
|
||||
MessagesFile string
|
||||
}
|
||||
|
||||
type ExpiryConfig struct {
|
||||
ReminderDays []int
|
||||
GraceDays int
|
||||
CronHourUTC int
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
URL string
|
||||
Secret string
|
||||
TimeoutSecs int
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Domain string
|
||||
Port int
|
||||
AdminAPIKey string
|
||||
FrontendURL string
|
||||
DatabasePath string
|
||||
|
||||
Lightning LightningConfig
|
||||
Nostr NostrConfig
|
||||
DM DMConfig
|
||||
Expiry ExpiryConfig
|
||||
Webhook WebhookConfig
|
||||
|
||||
LogLevel string
|
||||
RateLimitPerMin int
|
||||
ReservedUsernames []string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
c := &Config{
|
||||
Domain: env("DOMAIN", ""),
|
||||
Port: envInt("PORT", 8080),
|
||||
AdminAPIKey: env("ADMIN_API_KEY", ""),
|
||||
FrontendURL: env("FRONTEND_URL", ""),
|
||||
DatabasePath: env("DATABASE_PATH", ".data/nip05.db"),
|
||||
Lightning: LightningConfig{
|
||||
Enabled: envBool("LIGHTNING_ENABLED", true),
|
||||
LNbitsURL: env("LNBITS_URL", ""),
|
||||
LNbitsInvoiceKey: env("LNBITS_INVOICE_KEY", ""),
|
||||
PriceYearlySats: int64(envInt("PRICE_YEARLY_SATS", 1000)),
|
||||
PriceLifetimeSats: int64(envInt("PRICE_LIFETIME_SATS", 10000)),
|
||||
InvoiceExpiryMins: envInt("INVOICE_EXPIRY_MINUTES", 30),
|
||||
},
|
||||
Nostr: NostrConfig{
|
||||
Relays: csv(env("RELAYS", "")),
|
||||
UsernameSyncEnabled: envBool("USERNAME_SYNC_ENABLED", true),
|
||||
SyncIntervalMins: envInt("SYNC_INTERVAL_MINUTES", 15),
|
||||
},
|
||||
DM: DMConfig{
|
||||
Enabled: envBool("DM_ENABLED", true),
|
||||
Nsec: env("DM_NSEC", ""),
|
||||
Kind: envInt("DM_KIND", 1059),
|
||||
MessagesFile: env("MESSAGES_FILE", "messages.yaml"),
|
||||
},
|
||||
Expiry: ExpiryConfig{
|
||||
ReminderDays: csvInt(env("EXPIRY_REMINDER_DAYS", "7")),
|
||||
GraceDays: envInt("USERNAME_GRACE_DAYS", 30),
|
||||
CronHourUTC: envInt("EXPIRY_CRON_HOUR_UTC", 9),
|
||||
},
|
||||
Webhook: WebhookConfig{
|
||||
URL: env("WEBHOOK_URL", ""),
|
||||
Secret: env("WEBHOOK_SECRET", ""),
|
||||
TimeoutSecs: envInt("WEBHOOK_TIMEOUT_SECONDS", 10),
|
||||
MaxRetries: envInt("WEBHOOK_MAX_RETRIES", 5),
|
||||
},
|
||||
LogLevel: env("LOG_LEVEL", "info"),
|
||||
RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30),
|
||||
ReservedUsernames: csv(env("RESERVED_USERNAMES", "")),
|
||||
}
|
||||
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func csv(v string) []string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func csvInt(v string) []int {
|
||||
parts := csv(v)
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) }
|
||||
78
internal/config/validate.go
Normal file
78
internal/config/validate.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Validate(c *Config) error {
|
||||
var problems []string
|
||||
if c.Domain == "" {
|
||||
problems = append(problems, "DOMAIN is required")
|
||||
}
|
||||
if c.Port <= 0 || c.Port > 65535 {
|
||||
problems = append(problems, "PORT must be 1-65535")
|
||||
}
|
||||
if c.AdminAPIKey == "" || c.AdminAPIKey == "change-me-to-a-long-random-string" {
|
||||
problems = append(problems, "ADMIN_API_KEY must be set to a non-default value")
|
||||
} else if len(c.AdminAPIKey) < 24 {
|
||||
problems = append(problems, "ADMIN_API_KEY must be at least 24 characters")
|
||||
}
|
||||
if c.DatabasePath == "" {
|
||||
problems = append(problems, "DATABASE_PATH is required")
|
||||
}
|
||||
if c.Lightning.Enabled {
|
||||
if c.Lightning.LNbitsURL == "" {
|
||||
problems = append(problems, "LNBITS_URL is required when LIGHTNING_ENABLED=true")
|
||||
} else if !strings.HasPrefix(c.Lightning.LNbitsURL, "http://") && !strings.HasPrefix(c.Lightning.LNbitsURL, "https://") {
|
||||
problems = append(problems, "LNBITS_URL must start with http:// or https://")
|
||||
}
|
||||
if c.Lightning.LNbitsInvoiceKey == "" {
|
||||
problems = append(problems, "LNBITS_INVOICE_KEY is required when LIGHTNING_ENABLED=true")
|
||||
}
|
||||
if c.Lightning.PriceYearlySats <= 0 {
|
||||
problems = append(problems, "PRICE_YEARLY_SATS must be > 0")
|
||||
}
|
||||
if c.Lightning.PriceLifetimeSats <= 0 {
|
||||
problems = append(problems, "PRICE_LIFETIME_SATS must be > 0")
|
||||
}
|
||||
if c.Lightning.InvoiceExpiryMins <= 0 {
|
||||
problems = append(problems, "INVOICE_EXPIRY_MINUTES must be > 0")
|
||||
}
|
||||
}
|
||||
if c.DM.Enabled {
|
||||
if c.DM.Nsec == "" {
|
||||
problems = append(problems, "DM_NSEC is required when DM_ENABLED=true")
|
||||
}
|
||||
if c.DM.Kind != 4 && c.DM.Kind != 1059 {
|
||||
problems = append(problems, "DM_KIND must be 4 or 1059")
|
||||
}
|
||||
}
|
||||
if (c.Nostr.UsernameSyncEnabled || c.DM.Enabled) && len(c.Nostr.Relays) == 0 {
|
||||
problems = append(problems, "RELAYS is required when sync or DM is enabled")
|
||||
}
|
||||
for _, r := range c.Nostr.Relays {
|
||||
if !strings.HasPrefix(r, "ws://") && !strings.HasPrefix(r, "wss://") {
|
||||
problems = append(problems, "RELAYS entry must be ws:// or wss://, got "+r)
|
||||
}
|
||||
}
|
||||
if c.Webhook.URL != "" {
|
||||
if !strings.HasPrefix(c.Webhook.URL, "http://") && !strings.HasPrefix(c.Webhook.URL, "https://") {
|
||||
problems = append(problems, "WEBHOOK_URL must start with http:// or https://")
|
||||
}
|
||||
}
|
||||
if c.Expiry.GraceDays < 0 {
|
||||
problems = append(problems, "USERNAME_GRACE_DAYS must be >= 0")
|
||||
}
|
||||
if c.Expiry.CronHourUTC < 0 || c.Expiry.CronHourUTC > 23 {
|
||||
problems = append(problems, "EXPIRY_CRON_HOUR_UTC must be 0-23")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("invalid config:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrInvalidConfig = errors.New("invalid config")
|
||||
32
internal/config/validate_test.go
Normal file
32
internal/config/validate_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidate_Defaults(t *testing.T) {
|
||||
c := &Config{
|
||||
Domain: "azzamo.net",
|
||||
Port: 8080,
|
||||
AdminAPIKey: "long-secret-key-for-tests",
|
||||
DatabasePath: ".data/nip05.db",
|
||||
}
|
||||
if err := Validate(c); err != nil {
|
||||
t.Fatalf("expected ok, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingDomain(t *testing.T) {
|
||||
c := &Config{Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db"}
|
||||
if err := Validate(c); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_LightningRequiresLNbits(t *testing.T) {
|
||||
c := &Config{
|
||||
Domain: "azzamo.net", Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db",
|
||||
Lightning: LightningConfig{Enabled: true},
|
||||
}
|
||||
if err := Validate(c); err == nil {
|
||||
t.Fatal("expected error for missing LNBITS_URL")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user