Compare commits
2 Commits
fe2b95258d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dcd671043 | |||
| 43d78862e3 |
11
.env.example
11
.env.example
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type LightningConfig struct {
|
type LightningConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
LNbitsURL string
|
LNbitsURL string
|
||||||
LNbitsInvoiceKey string
|
LNbitsInvoiceKey string
|
||||||
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) {
|
||||||
@@ -77,12 +79,14 @@ func Load() (*Config, error) {
|
|||||||
FrontendURL: env("FRONTEND_URL", ""),
|
FrontendURL: env("FRONTEND_URL", ""),
|
||||||
DatabasePath: env("DATABASE_PATH", ".data/nip05.db"),
|
DatabasePath: env("DATABASE_PATH", ".data/nip05.db"),
|
||||||
Lightning: LightningConfig{
|
Lightning: LightningConfig{
|
||||||
Enabled: envBool("LIGHTNING_ENABLED", true),
|
Enabled: envBool("LIGHTNING_ENABLED", true),
|
||||||
LNbitsURL: env("LNBITS_URL", ""),
|
LNbitsURL: env("LNBITS_URL", ""),
|
||||||
LNbitsInvoiceKey: env("LNBITS_INVOICE_KEY", ""),
|
LNbitsInvoiceKey: env("LNBITS_INVOICE_KEY", ""),
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ func NewLNbits(baseURL, invoiceKey string) *LNbitsClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type createReq struct {
|
type createReq struct {
|
||||||
Out bool `json:"out"`
|
Out bool `json:"out"`
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user