first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

171
internal/config/config.go Normal file
View 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) }

View 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")

View 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")
}
}