Compare commits

...

2 Commits

Author SHA1 Message Date
5dcd671043 Support comma-separated CORS_HEADER for multiple origins.
Parse CORS_HEADER as a list: * for all origins, or reflect matching
request Origin when multiple specific origins are configured. Add Vary:
Origin for the allowlist case. Update .env.example and CORS tests.
2026-05-06 20:38:28 +00:00
43d78862e3 Add configurable LNbits invoice memos and pubkey metadata
Read INVOICE_MEMO_YEARLY and INVOICE_MEMO_LIFETIME from the environment
and pass the user pubkey in LNbits payment extra for invoice creation.
2026-05-06 19:52:07 +00:00
7 changed files with 91 additions and 96 deletions

View File

@@ -4,11 +4,10 @@ PORT=8080
ADMIN_API_KEY=change-me-to-a-long-random-string ADMIN_API_KEY=change-me-to-a-long-random-string
FRONTEND_URL=https://azzamo.net/nip05 FRONTEND_URL=https://azzamo.net/nip05
# Optional extra browser origins (comma-separated). Merged with FRONTEND_URL for CORS. # --- CORS ---
# CORS_ORIGINS= # Comma-separated list of allowed origins, or "*" to allow all.
# Examples: "*" | "https://azzamo.net" | "https://azzamo.net,https://other.example"
# Allow http(s)://localhost:* and 127.0.0.1 for local UI dev hitting this API directly (not via Vite proxy). CORS_HEADER=*
CORS_ALLOW_LOCALHOST=true
# --- Database --- # --- Database ---
DATABASE_PATH=.data/nip05.db DATABASE_PATH=.data/nip05.db
@@ -20,6 +19,8 @@ LNBITS_INVOICE_KEY=your-lnbits-invoice-read-key
PRICE_YEARLY_SATS=1000 PRICE_YEARLY_SATS=1000
PRICE_LIFETIME_SATS=10000 PRICE_LIFETIME_SATS=10000
INVOICE_EXPIRY_MINUTES=30 INVOICE_EXPIRY_MINUTES=30
INVOICE_MEMO_YEARLY=Noderunners Relay yearly Access
INVOICE_MEMO_LIFETIME=Noderunners Relay lifetime Access
# --- Nostr --- # --- Nostr ---
RELAYS=wss://relay.azzamo.net,wss://nostr.azzamo.net,wss://wot.azzamo.net RELAYS=wss://relay.azzamo.net,wss://nostr.azzamo.net,wss://wot.azzamo.net

View File

@@ -102,6 +102,8 @@ func run() error {
YearlySats: cfg.Lightning.PriceYearlySats, YearlySats: cfg.Lightning.PriceYearlySats,
LifetimeSats: cfg.Lightning.PriceLifetimeSats, LifetimeSats: cfg.Lightning.PriceLifetimeSats,
ExpiryMins: cfg.Lightning.InvoiceExpiryMins, ExpiryMins: cfg.Lightning.InvoiceExpiryMins,
MemoYearly: cfg.Lightning.InvoiceMemoYearly,
MemoLifetime: cfg.Lightning.InvoiceMemoLifetime,
}, cfg.Domain) }, cfg.Domain)
} }

View File

@@ -16,6 +16,8 @@ type LightningConfig struct {
PriceYearlySats int64 PriceYearlySats int64
PriceLifetimeSats int64 PriceLifetimeSats int64
InvoiceExpiryMins int InvoiceExpiryMins int
InvoiceMemoYearly string
InvoiceMemoLifetime string
} }
type NostrConfig struct { type NostrConfig struct {
@@ -61,10 +63,10 @@ type Config struct {
RateLimitPerMin int RateLimitPerMin int
ReservedUsernames []string ReservedUsernames []string
// CORS: exact origin list = FRONTEND_URL CORS_ORIGINS; loopback hosts if CORS_ALLOW_LOCALHOST. // CORSOrigins is parsed from the CORS_HEADER env var (comma-separated).
CORSExtraOrigins []string // Use "*" to allow all origins, or list specific origins like
CORSAllowLocalhost bool // "https://example.com,https://other.example".
CORSAllowCredentials bool CORSOrigins []string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -83,6 +85,8 @@ func Load() (*Config, error) {
PriceYearlySats: int64(envInt("PRICE_YEARLY_SATS", 1000)), PriceYearlySats: int64(envInt("PRICE_YEARLY_SATS", 1000)),
PriceLifetimeSats: int64(envInt("PRICE_LIFETIME_SATS", 10000)), PriceLifetimeSats: int64(envInt("PRICE_LIFETIME_SATS", 10000)),
InvoiceExpiryMins: envInt("INVOICE_EXPIRY_MINUTES", 30), InvoiceExpiryMins: envInt("INVOICE_EXPIRY_MINUTES", 30),
InvoiceMemoYearly: env("INVOICE_MEMO_YEARLY", "Noderunners Relay yearly Access"),
InvoiceMemoLifetime: env("INVOICE_MEMO_LIFETIME", "Noderunners Relay lifetime Access"),
}, },
Nostr: NostrConfig{ Nostr: NostrConfig{
Relays: csv(env("RELAYS", "")), Relays: csv(env("RELAYS", "")),
@@ -109,9 +113,7 @@ func Load() (*Config, error) {
LogLevel: env("LOG_LEVEL", "info"), LogLevel: env("LOG_LEVEL", "info"),
RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30), RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30),
ReservedUsernames: csv(env("RESERVED_USERNAMES", "")), ReservedUsernames: csv(env("RESERVED_USERNAMES", "")),
CORSExtraOrigins: csv(env("CORS_ORIGINS", "")), CORSOrigins: csv(env("CORS_HEADER", "*")),
CORSAllowLocalhost: envBool("CORS_ALLOW_LOCALHOST", true),
CORSAllowCredentials: envBool("CORS_ALLOW_CREDENTIALS", false),
} }
if err := Validate(c); err != nil { if err := Validate(c); err != nil {
@@ -177,22 +179,3 @@ func csvInt(v string) []int {
} }
func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) } func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) }
// CORSExactOrigins lists allowed browser Origins for exact match (before loopback wildcard).
func (c *Config) CORSExactOrigins() []string {
seen := make(map[string]bool)
out := make([]string, 0, 4+len(c.CORSExtraOrigins))
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(c.FrontendURL)
for _, o := range c.CORSExtraOrigins {
add(o)
}
return out
}

View File

@@ -2,27 +2,44 @@ package middleware
import ( import (
"net/http" "net/http"
"net/url"
"strings"
"github.com/noderunners/nip05api/internal/config" "github.com/noderunners/nip05api/internal/config"
) )
// CORS sends at most one Access-Control-Allow-Origin value (echo of request Origin). // CORS sets Access-Control-Allow-Origin based on the CORS_HEADER env var.
// Configure FRONTEND_URL, optional CORS_ORIGINS, and CORS_ALLOW_LOCALHOST / CORS_ALLOW_CREDENTIALS. //
// Supports "*" (allow all), a single origin, or a comma-separated list.
// When multiple origins are configured the middleware reflects the request
// Origin back if it matches one of the allowed values (the HTTP spec forbids
// sending more than one origin in the header).
func CORS(cfg *config.Config) func(http.Handler) http.Handler { func CORS(cfg *config.Config) func(http.Handler) http.Handler {
allowAll := len(cfg.CORSOrigins) == 1 && cfg.CORSOrigins[0] == "*"
allowed := make(map[string]bool, len(cfg.CORSOrigins))
for _, o := range cfg.CORSOrigins {
allowed[o] = true
}
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin") var origin string
if allowAll {
origin = "*"
} else {
reqOrigin := r.Header.Get("Origin")
if allowed[reqOrigin] {
origin = reqOrigin
}
}
if origin != "" && originAllowed(origin, cfg) { if origin != "" {
h := w.Header() h := w.Header()
h.Set("Access-Control-Allow-Origin", origin) h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
h.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization") h.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
h.Set("Access-Control-Max-Age", "86400") h.Set("Access-Control-Max-Age", "86400")
if cfg.CORSAllowCredentials { if !allowAll {
h.Set("Access-Control-Allow-Credentials", "true") h.Set("Vary", "Origin")
} }
} }
@@ -35,36 +52,3 @@ func CORS(cfg *config.Config) func(http.Handler) http.Handler {
}) })
} }
} }
func originAllowed(origin string, cfg *config.Config) bool {
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
for _, allowed := range cfg.CORSExactOrigins() {
if origin == allowed {
return true
}
}
if cfg.CORSAllowLocalhost && isLoopbackOrigin(u) {
return true
}
return false
}
func isLoopbackOrigin(u *url.URL) bool {
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
switch host {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}

View File

@@ -48,6 +48,7 @@ func newFixture(t *testing.T) *fixture {
Expiry: config.ExpiryConfig{GraceDays: 30}, Expiry: config.ExpiryConfig{GraceDays: 30},
ReservedUsernames: []string{"admin", "root"}, ReservedUsernames: []string{"admin", "root"},
RateLimitPerMin: 0, // disabled in tests RateLimitPerMin: 0, // disabled in tests
CORSOrigins: []string{"*"},
} }
tmpls, _ := messages.Load("/nonexistent.yaml") tmpls, _ := messages.Load("/nonexistent.yaml")
users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames) users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames)
@@ -553,6 +554,29 @@ func TestDocsPage(t *testing.T) {
} }
} }
func TestCORSHeader(t *testing.T) {
f := newFixture(t)
resp, _ := f.get(t, "/healthz")
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("expected Access-Control-Allow-Origin=*, got %q", got)
}
req, _ := http.NewRequest("OPTIONS", f.srv.URL+"/v1/pricing", nil)
req.Header.Set("Origin", "https://random-frontend.example")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 204 on OPTIONS preflight, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("expected Access-Control-Allow-Origin=*, got %q", got)
}
}
func TestBodyLimit(t *testing.T) { func TestBodyLimit(t *testing.T) {
f := newFixture(t) f := newFixture(t)
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB

View File

@@ -30,6 +30,7 @@ type createReq struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Memo string `json:"memo"` Memo string `json:"memo"`
Expiry int `json:"expiry,omitempty"` Expiry int `json:"expiry,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
} }
type createResp struct { type createResp struct {
@@ -47,8 +48,8 @@ type statusResp struct {
} `json:"details"` } `json:"details"`
} }
func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int) (string, string, error) { func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int, extra map[string]string) (string, string, error) {
body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs}) body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs, Extra: extra})
if err != nil { if err != nil {
return "", "", err return "", "", err
} }

View File

@@ -20,6 +20,8 @@ type Pricing struct {
YearlySats int64 YearlySats int64
LifetimeSats int64 LifetimeSats int64
ExpiryMins int ExpiryMins int
MemoYearly string
MemoLifetime string
} }
type Service struct { type Service struct {
@@ -131,17 +133,15 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoic
} }
amount := s.pricing.YearlySats * int64(req.Years) amount := s.pricing.YearlySats * int64(req.Years)
memo := s.pricing.MemoYearly
if req.SubscriptionType == user.SubLifetime { if req.SubscriptionType == user.SubLifetime {
amount = s.pricing.LifetimeSats amount = s.pricing.LifetimeSats
} memo = s.pricing.MemoLifetime
memo := fmt.Sprintf("%s@%s", username, s.domain)
if isRenewal {
memo = "renewal: " + memo
} }
expirySecs := s.pricing.ExpiryMins * 60 expirySecs := s.pricing.ExpiryMins * 60
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs) extra := map[string]string{"pubkey": req.Pubkey}
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs, extra)
if err != nil { if err != nil {
return nil, err return nil, err
} }