commit 2cb17df4c535767d6cbef1e79fe66dba6799a98c Author: Michilis Date: Wed Apr 29 02:35:00 2026 +0000 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f05eaa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.github +.data +.env +bin +*.test +*.out +README.md +SPEC.md +deploy +.golangci.yml +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..217d85f --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# --- Core --- +DOMAIN=azzamo.net +PORT=8080 +ADMIN_API_KEY=change-me-to-a-long-random-string +FRONTEND_URL=https://azzamo.net/nip05 + +# --- Database --- +DATABASE_PATH=.data/nip05.db + +# --- Lightning (LNbits) --- +LIGHTNING_ENABLED=true +LNBITS_URL=https://lnbits.azzamo.net +LNBITS_INVOICE_KEY=your-lnbits-invoice-read-key +PRICE_YEARLY_SATS=1000 +PRICE_LIFETIME_SATS=10000 +INVOICE_EXPIRY_MINUTES=30 + +# --- Nostr --- +RELAYS=wss://relay.azzamo.net,wss://nostr.azzamo.net,wss://wot.azzamo.net +USERNAME_SYNC_ENABLED=true +SYNC_INTERVAL_MINUTES=15 + +# --- DMs --- +DM_ENABLED=true +DM_NSEC=nsec1... +DM_KIND=1059 +MESSAGES_FILE=messages.yaml + +# --- Expiry & grace --- +EXPIRY_REMINDER_DAYS=7 +USERNAME_GRACE_DAYS=30 +EXPIRY_CRON_HOUR_UTC=9 + +# --- Webhook (optional) --- +WEBHOOK_URL= +WEBHOOK_SECRET= +WEBHOOK_TIMEOUT_SECONDS=10 +WEBHOOK_MAX_RETRIES=5 + +# --- Operational --- +LOG_LEVEL=info +RATE_LIMIT_PER_MIN=30 +RESERVED_USERNAMES=admin,root,support,help,abuse,postmaster,_ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9ecbfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.data/ +.env +/messages.yaml +bin/ +*.test +*.out +.DS_Store +.claude/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b3c9518 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,57 @@ +run: + timeout: 3m + tests: true + +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - misspell + - bodyclose + - errorlint + - gofmt + - goimports + - revive + - unconvert + - gocritic + - gosec + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + govet: + enable-all: true + disable: + - fieldalignment + - shadow + revive: + rules: + - name: var-naming + - name: package-comments + disabled: true + - name: exported + disabled: true + gosec: + excludes: + - G104 # we audit-log errors instead of failing requests + - G404 # math/rand is fine for non-crypto paths + - G115 # cast safety; manual review + +issues: + exclude-rules: + - path: _test\.go + linters: + - gosec + - errcheck + - gocritic + - path: cmd/nip05api/main\.go + linters: + - gocritic + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9717330 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.6 + +FROM golang:1.22-alpine AS build +WORKDIR /src + +ENV CGO_ENABLED=0 GOFLAGS="-trimpath" + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +ARG VERSION=dev +RUN go build -ldflags="-s -w -X main.version=${VERSION}" \ + -o /out/nip05api ./cmd/nip05api + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app + +COPY --from=build /out/nip05api /app/nip05api +COPY messages.example.yaml /app/messages.yaml + +USER nonroot:nonroot +EXPOSE 8080 + +ENTRYPOINT ["/app/nip05api"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7b5a884 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Noderunners + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f1a7330 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: build run test test-race lint clean docker tidy install-systemd + +GO ?= go +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) + +build: + CGO_ENABLED=0 $(GO) build -ldflags "$(LDFLAGS)" -o bin/nip05api ./cmd/nip05api + +run: + $(GO) run ./cmd/nip05api + +test: + $(GO) test ./... -count=1 + +test-race: + $(GO) test ./... -race -count=1 + +lint: + golangci-lint run + +tidy: + $(GO) mod tidy + +clean: + rm -rf bin/ .data/ + +docker: + docker build --build-arg VERSION=$(VERSION) -t nip05api:$(VERSION) -t nip05api:latest . + +# Install onto a Linux host running systemd. Run as root. +# Assumes the binary has already been built into ./bin/nip05api. +install-systemd: + id nip05 >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -d /opt/nip05api nip05 + install -d -m 0755 -o nip05 -g nip05 /opt/nip05api/.data + install -d -m 0755 -o nip05 -g nip05 /opt/nip05api/bin + install -m 0755 -o nip05 -g nip05 bin/nip05api /opt/nip05api/bin/nip05api + install -m 0644 -o nip05 -g nip05 messages.example.yaml /opt/nip05api/messages.yaml + install -m 0644 -o root -g root deploy/nip05api.service /etc/systemd/system/nip05api.service + systemctl daemon-reload + @echo "Installed. Now: copy your .env to /opt/nip05api/.env, then 'systemctl enable --now nip05api'." diff --git a/README.md b/README.md new file mode 100644 index 0000000..614ad41 --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# nip05api + +A single-domain NIP-05 identity service. Lightning-paid registration through +LNbits. Lifecycle DMs over Nostr. Optional outbound webhook on payment events. +Swagger docs. One binary, one SQLite file, one systemd unit. + +See [SPEC.md](SPEC.md) for the full design specification. + +## Features + +- NIP-05 lookup at `/.well-known/nostr.json` for active subscribers +- Yearly or lifetime subscriptions, BOLT11 invoices via LNbits +- Renewal detection by pubkey: same user, same username, extended expiry +- Configurable expiry-reminder DMs and grace-period username reservation +- Profile sync: pulls `kind:0` from configured relays and updates non-pinned usernames +- HMAC-signed webhook outbox with exponential retry +- NIP-04 or NIP-17 (gift-wrapped) DM delivery +- Pure-Go SQLite, no CGO; static binary under 20 MB +- Embedded migrations applied on boot +- Embedded OpenAPI spec served at `/openapi.json`, Swagger UI at `/docs` + +## Quick start + +```bash +git clone https://github.com/noderunners/nip05api.git +cd nip05api +cp .env.example .env +cp messages.example.yaml messages.yaml +# Edit .env: DOMAIN, ADMIN_API_KEY, LNBITS_*, DM_NSEC, RELAYS +make build +./bin/nip05api +``` + +The binary creates `.data/nip05.db`, applies migrations, and starts listening +on `PORT` (default `8080`). Hit `http://localhost:8080/healthz` to confirm. + +## Configuration + +All config is environment-driven. See [.env.example](.env.example) for the full +list. Required keys: + +| Key | Notes | +|---|---| +| `DOMAIN` | the apex domain users will be `name@domain` on | +| `ADMIN_API_KEY` | 24+ chars; sent as `X-API-Key` on `/v1/admin/*` | +| `LNBITS_URL`, `LNBITS_INVOICE_KEY` | required when `LIGHTNING_ENABLED=true` | +| `RELAYS` | comma-separated `wss://...`, required when DM or sync enabled | +| `DM_NSEC` | required when `DM_ENABLED=true` | + +Subsystems toggle independently: `LIGHTNING_ENABLED`, `DM_ENABLED`, +`USERNAME_SYNC_ENABLED`, plus webhook is enabled by setting `WEBHOOK_URL`. + +## API surface + +Eleven endpoints, full schema at `/openapi.json` and rendered at `/docs`: + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| GET | `/.well-known/nostr.json` | — | NIP-05 lookup | +| GET | `/healthz` | — | DB ping + version | +| GET | `/v1/pricing` | — | yearly/lifetime sat pricing | +| GET | `/v1/users/{pubkey}` | — | lookup by hex or npub | +| GET | `/v1/usernames/{name}/available` | — | availability check | +| POST | `/v1/invoices` | — | create payment invoice | +| GET | `/v1/invoices/{hash}` | — | invoice status | +| POST | `/v1/admin/users` | api key | manual add | +| GET | `/v1/admin/users` | api key | list / search | +| PUT | `/v1/admin/users/{pubkey}` | api key | pin username | +| DELETE | `/v1/admin/users/{pubkey}` | api key | hard delete | +| POST | `/v1/admin/users/{pubkey}/extend` | api key | extend or upgrade | + +## Deployment + +### systemd (recommended for VMs) + +```bash +make build +sudo make install-systemd +sudo cp .env /opt/nip05api/.env +sudo systemctl enable --now nip05api +journalctl -u nip05api -f +``` + +The unit is hardened (no-new-privileges, ProtectSystem=strict, capability-bound +to nothing) and runs as a dedicated `nip05` user. See +[deploy/nip05api.service](deploy/nip05api.service). + +### Docker + +```bash +docker build -t nip05api:latest . +docker compose up -d +``` + +The image is multi-stage and ships from `gcr.io/distroless/static-debian12:nonroot`. +Compose binds the data directory and `messages.yaml` from the host so updates +to copy don't require a rebuild. + +### Reverse proxy + +A reverse proxy terminates TLS and forwards `/.well-known/nostr.json`, `/v1/*`, +`/healthz`, `/openapi.json`, and `/docs` to `127.0.0.1:8080`. + +Sample configs: +- nginx → [deploy/nginx.conf](deploy/nginx.conf) +- Caddy → [deploy/Caddyfile](deploy/Caddyfile) + +The proxy must forward `X-Forwarded-For` or `X-Real-IP`; the API uses these +for rate limiting and access logs. + +## Operations + +### Database + +SQLite WAL-mode at `DATABASE_PATH` (default `.data/nip05.db`). Backups while +the service is running: + +```bash +sqlite3 .data/nip05.db ".backup .data/backup-$(date +%F).db" +``` + +WAL files (`-wal`, `-shm`) are checkpointed on graceful shutdown. To rotate: +stop the service, copy `nip05.db`, `nip05.db-wal`, `nip05.db-shm` together, +restart. + +### Migrations + +Embedded as `.sql` files under `internal/db/migrations/`. They run inside a +transaction on every boot and record their version in `schema_migrations`. +Applying twice is a no-op. To add a migration: drop a new +`NNNN_description.sql` next to the others; rebuild. + +### Audit log + +Admin actions, payment confirmations, expiries, and grace-period purges all +write to `audit_log`. Cleanup worker prunes entries older than 180 days +hourly. + +### Outbox cleanup + +Delivered webhook and DM rows are pruned after 7 days; dead (gave up after +all retries) after 30 days. Tunable in `cmd/nip05api/cleanup.go`. + +### Crash safety + +- Migrations: transactional, idempotent. +- Payment confirmation: idempotent. The first confirmation captures + `target_expires_at` on the invoice row; subsequent retries apply the same + absolute value, so a crash mid-confirm cannot double-extend a renewal. +- Webhook delivery: outbox + retry schedule (`30s, 2m, 10m, 1h, 6h`), + HMAC-signed if `WEBHOOK_SECRET` is set. +- DM delivery: outbox + retry schedule (`1m, 5m, 30m, 2h, 12h`). +- Expiry pass: each phase (reminders → expirations → grace cleanup) updates + idempotent flags, so partial completion is safe to resume. +- Workers all watch a single root context; SIGINT/SIGTERM closes them in + order with a 30s HTTP grace window. + +### Observability + +- Logs: structured JSON to stdout via `slog`; ship to journald or your + logging stack of choice. `request_id` is propagated through middleware. +- Secrets that never appear in logs: `ADMIN_API_KEY`, `LNBITS_INVOICE_KEY`, + `WEBHOOK_SECRET`, `DM_NSEC`, BOLT11 strings, DM plaintext. +- Health: `/healthz` returns 200 with version + db status, 503 if DB unreachable. + +## Development + +```bash +make tidy # go mod tidy +make build # static binary in bin/ +make test # short tests +make test-race # all tests with -race +make lint # golangci-lint, see .golangci.yml +make docker # build container image +``` + +Module layout: + +``` +cmd/nip05api/ entry point + cleanup worker +internal/audit/ append-only audit log +internal/config/ env loader + validation +internal/db/ SQLite + embedded migrations +internal/dm/ outbox + NIP-04/NIP-17 builder + worker +internal/expiry/ daily reminders / expirations / grace cleanup +internal/http/ chi router, middleware, handlers, OpenAPI docs +internal/invoice/ LNbits client + service + repo +internal/log/ slog setup +internal/messages/ YAML template loader + renderer +internal/nostr/ keys, relay pool, profile fetch, publish +internal/payments/ poll LNbits → confirm → dispatch DM/webhook/audit +internal/sync/ periodic kind:0 username sync +internal/user/ repo + service +internal/webhook/ outbox + HMAC signer + worker +``` + +Each Go file is kept under ~200 lines. The dependency graph points downward: +handlers depend on services, services on repos, repos on `db`. Nothing in +`internal/` depends on `internal/http/`. + +## License + +See [LICENSE](LICENSE) (MIT). diff --git a/cmd/nip05api/cleanup.go b/cmd/nip05api/cleanup.go new file mode 100644 index 0000000..b700a89 --- /dev/null +++ b/cmd/nip05api/cleanup.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +// cleanupWorker prunes old delivered/dead outbox rows hourly so the DB stays +// small. Delivered: 7 days. Dead: 30 days (kept longer for forensic value). +type cleanupWorker struct { + db *db.DB +} + +func newCleanupWorker(d *db.DB) *cleanupWorker { return &cleanupWorker{db: d} } + +func (c *cleanupWorker) Run(ctx context.Context) { + t := time.NewTicker(1 * time.Hour) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.prune(ctx) + } + } +} + +func (c *cleanupWorker) prune(ctx context.Context) { + queries := []struct { + label string + query string + args []any + }{ + {"webhook delivered", `DELETE FROM webhook_outbox WHERE status = 'delivered' AND created_at < ?`, + []any{time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)}}, + {"webhook dead", `DELETE FROM webhook_outbox WHERE status = 'dead' AND created_at < ?`, + []any{time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)}}, + {"dm delivered", `DELETE FROM dm_outbox WHERE status = 'delivered' AND created_at < ?`, + []any{time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)}}, + {"dm dead", `DELETE FROM dm_outbox WHERE status = 'dead' AND created_at < ?`, + []any{time.Now().UTC().Add(-30 * 24 * time.Hour).Format(time.RFC3339)}}, + {"audit", `DELETE FROM audit_log WHERE created_at < ?`, + []any{time.Now().UTC().Add(-180 * 24 * time.Hour).Format(time.RFC3339)}}, + } + for _, q := range queries { + res, err := c.db.ExecContext(ctx, q.query, q.args...) + if err != nil { + slog.Warn("cleanup", "label", q.label, "err", err) + continue + } + if n, _ := res.RowsAffected(); n > 0 { + slog.Info("cleanup", "label", q.label, "rows", n) + } + } +} diff --git a/cmd/nip05api/main.go b/cmd/nip05api/main.go new file mode 100644 index 0000000..281bcb6 --- /dev/null +++ b/cmd/nip05api/main.go @@ -0,0 +1,175 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/config" + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/expiry" + httpapi "github.com/noderunners/nip05api/internal/http" + "github.com/noderunners/nip05api/internal/invoice" + applog "github.com/noderunners/nip05api/internal/log" + "github.com/noderunners/nip05api/internal/messages" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/payments" + syncw "github.com/noderunners/nip05api/internal/sync" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +// version is overridden at build time via -ldflags "-X main.version=..." +var version = "dev" + +func main() { + if err := run(); err != nil { + slog.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + applog.Setup("info") + return err + } + applog.Setup(cfg.LogLevel) + slog.Info("starting", + "version", version, + "domain", cfg.Domain, + "port", cfg.Port, + "lightning", cfg.Lightning.Enabled, + "dm", cfg.DM.Enabled, + "sync", cfg.Nostr.UsernameSyncEnabled, + "webhook", cfg.Webhook.URL != "", + ) + + database, err := db.Open(cfg.DatabasePath) + if err != nil { + return err + } + defer database.Close() + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := database.Migrate(ctx); err != nil { + return err + } + + tmpls, err := messages.Load(cfg.DM.MessagesFile) + if err != nil { + return err + } + + userRepo := user.NewRepo(database) + userSvc := user.NewService(userRepo, cfg.ReservedUsernames) + + auditLogger := audit.New(database) + + pool := nostr.NewPool(cfg.Nostr.Relays) + defer pool.Close() + + hookRepo := webhook.NewRepo(database) + hookSvc := webhook.NewService(hookRepo, cfg.Domain, cfg.Webhook.URL != "") + hookWorker := webhook.NewWorker(hookRepo, cfg.Webhook.URL, cfg.Webhook.Secret, + cfg.Webhook.TimeoutSecs, cfg.Webhook.MaxRetries) + + dmRepo := dm.NewRepo(database) + dmSvc := dm.NewService(dmRepo, tmpls, cfg.DM.Enabled) + var dmWorker *dm.Worker + if cfg.DM.Enabled { + dmWorker, err = dm.NewWorker(dmRepo, pool, cfg.DM.Nsec, cfg.DM.Kind) + if err != nil { + return err + } + } + + var invSvc *invoice.Service + var lnClient *invoice.LNbitsClient + if cfg.Lightning.Enabled { + lnClient = invoice.NewLNbits(cfg.Lightning.LNbitsURL, cfg.Lightning.LNbitsInvoiceKey) + invRepo := invoice.NewRepo(database) + invSvc = invoice.NewService(invRepo, userSvc, lnClient, + invoice.Pricing{ + YearlySats: cfg.Lightning.PriceYearlySats, + LifetimeSats: cfg.Lightning.PriceLifetimeSats, + ExpiryMins: cfg.Lightning.InvoiceExpiryMins, + }, cfg.Domain) + } + + payWorker := payments.NewWorker(invSvc, userSvc, lnClient, dmSvc, hookSvc, auditLogger, + cfg.Domain, cfg.FrontendURL, cfg.Lightning.Enabled && invSvc != nil) + syncWorker := syncw.NewWorker(userSvc, pool, cfg.Nostr.SyncIntervalMins, + cfg.Nostr.UsernameSyncEnabled, cfg.Domain, cfg.ReservedUsernames) + expiryWorker := expiry.NewWorker(userSvc, dmSvc, hookSvc, auditLogger, + cfg.Domain, cfg.FrontendURL, cfg.Expiry.GraceDays, + cfg.Expiry.ReminderDays, cfg.Expiry.CronHourUTC) + + cleanupWorker := newCleanupWorker(database) + + srv := httpapi.NewServer(httpapi.Deps{ + Cfg: cfg, + DB: database, + Users: userSvc, + Invoices: invSvc, + DMs: dmSvc, + Hooks: hookSvc, + Audit: auditLogger, + Version: version, + }) + + var wg sync.WaitGroup + startWorker(&wg, ctx, "webhook", hookWorker.Run) + if dmWorker != nil { + startWorker(&wg, ctx, "dm", dmWorker.Run) + } + startWorker(&wg, ctx, "payments", payWorker.Run) + startWorker(&wg, ctx, "sync", syncWorker.Run) + startWorker(&wg, ctx, "expiry", expiryWorker.Run) + startWorker(&wg, ctx, "cleanup", cleanupWorker.Run) + + serverErr := make(chan error, 1) + go func() { + slog.Info("http listening", "addr", cfg.Addr()) + if err := srv.ListenAndServe(); err != nil && err.Error() != "http: Server closed" { + serverErr <- err + } + close(serverErr) + }() + + select { + case <-ctx.Done(): + slog.Info("shutdown signal received") + case err := <-serverErr: + if err != nil { + cancel() + wg.Wait() + return err + } + } + + if err := httpapi.Shutdown(context.Background(), srv); err != nil { + slog.Error("server shutdown", "err", err) + } + wg.Wait() + slog.Info("shutdown complete") + return nil +} + +func startWorker(wg *sync.WaitGroup, ctx context.Context, name string, run func(context.Context)) { + wg.Add(1) + go func() { + defer wg.Done() + defer slog.Info("worker stopped", "name", name) + slog.Info("worker started", "name", name) + run(ctx) + }() +} diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..622aeab --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,34 @@ +# Caddy alternative to nginx. Replace example.com with your DOMAIN. +# +# Caddy auto-issues TLS via Let's Encrypt by default. + +example.com { + encode gzip zstd + + header { + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + # Strict-Transport-Security "max-age=31536000; includeSubDomains" + } + + request_body { + max_size 1MB + } + + @nostrjson path /.well-known/nostr.json + handle @nostrjson { + reverse_proxy 127.0.0.1:8080 + header Cache-Control "public, max-age=60" + header Access-Control-Allow-Origin "*" + } + + @api path /v1/* /healthz /openapi.json /docs /docs/* + handle @api { + reverse_proxy 127.0.0.1:8080 + } + + handle { + respond "Not found" 404 + } +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..0df489d --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,62 @@ +# Example nginx config for nip05api. +# Replace example.com with your DOMAIN. + +upstream nip05api { + server 127.0.0.1:8080; + keepalive 16; +} + +# HTTP → HTTPS redirect (assumes certbot or equivalent has terminated TLS). +server { + listen 80; + listen [::]:80; + server_name example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name example.com; + + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + + # HSTS — opt in once you're confident HTTPS is permanent. + # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 1m; + + # NIP-05 well-known endpoint MUST be served on the apex domain. + location = /.well-known/nostr.json { + proxy_pass http://nip05api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + + add_header Access-Control-Allow-Origin "*" always; + add_header Cache-Control "public, max-age=60" always; + } + + location ~ ^/(v1/|healthz|version|openapi.json|docs) { + proxy_pass http://nip05api; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 30s; + } +} diff --git a/deploy/nip05api.service b/deploy/nip05api.service new file mode 100644 index 0000000..8b36804 --- /dev/null +++ b/deploy/nip05api.service @@ -0,0 +1,45 @@ +[Unit] +Description=NIP-05 API +Documentation=https://github.com/noderunners/nip05api +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=nip05 +Group=nip05 +WorkingDirectory=/opt/nip05api +EnvironmentFile=/opt/nip05api/.env +ExecStart=/opt/nip05api/bin/nip05api +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ReadWritePaths=/opt/nip05api/.data +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= + +# Limits +LimitNOFILE=65536 +TimeoutStopSec=45 + +# Logging to stdout/stderr → journald +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be643bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + nip05api: + build: + context: . + args: + VERSION: ${VERSION:-dev} + image: nip05api:latest + container_name: nip05api + restart: unless-stopped + ports: + - "127.0.0.1:8080:8080" + env_file: + - .env + volumes: + - ./.data:/app/.data + - ./messages.yaml:/app/messages.yaml:ro + # Healthcheck: distroless has no shell, so probe externally. + # Recommended: Caddy/nginx upstream probe or your monitoring system. + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3f94414 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module github.com/noderunners/nip05api + +go 1.24.1 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/httprate v0.14.1 + github.com/joho/godotenv v1.5.1 + github.com/nbd-wtf/go-nostr v0.51.12 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.34.1 +) + +require ( + github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect + github.com/btcsuite/btcd/btcutil v1.1.5 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/bytedance/sonic v1.13.1 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/crypto v0.36.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2d1a540 --- /dev/null +++ b/go.sum @@ -0,0 +1,228 @@ +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 h1:ClzzXMDDuUbWfNNZqGeYq4PnYOlwlOVIvSyNaIy0ykg= +github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3/go.mod h1:we0YA5CsBbH5+/NUzC/AlMmxaDtWlXeNsqrwXjTzmzA= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.4 h1:3EJjcN70HCu/mwqlUsGK8GcNVyLVxFDlWurTXGPFfiQ= +github.com/btcsuite/btcd/btcec/v2 v2.3.4/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.13.1 h1:Jyd5CIvdFnkOWuKXr+wm4Nyk2h0yAFsr8ucJgEasO3g= +github.com/bytedance/sonic v1.13.1/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= +github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= +github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nbd-wtf/go-nostr v0.51.12 h1:MRQcrShiW/cHhnYSVDQ4SIEc7DlYV7U7gg/l4H4gbbE= +github.com/nbd-wtf/go-nostr v0.51.12/go.mod h1:IF30/Cm4AS90wd1GjsFJbBqq7oD1txo+2YUFYXqK3Nc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..a1e6c28 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,44 @@ +package audit + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/noderunners/nip05api/internal/db" +) + +const ( + ActorAdmin = "admin" + ActorSystem = "system" + + ActionUserAdded = "user.added" + ActionUserUsernameChanged = "user.username_changed" + ActionUserExtended = "user.extended" + ActionUserDeleted = "user.deleted" + ActionPaymentConfirmed = "payment.confirmed" + ActionUserExpired = "user.expired" + ActionUserGracePurged = "user.grace_purged" +) + +type Logger struct{ db *db.DB } + +func New(d *db.DB) *Logger { return &Logger{db: d} } + +func (l *Logger) Log(ctx context.Context, action, actor, pubkey string, details map[string]any) { + if l == nil || l.db == nil { + return + } + var detailsJSON string + if details != nil { + b, err := json.Marshal(details) + if err == nil { + detailsJSON = string(b) + } + } + if _, err := l.db.ExecContext(ctx, + `INSERT INTO audit_log (action, actor, pubkey, details) VALUES (?, ?, ?, ?)`, + action, actor, pubkey, detailsJSON); err != nil { + slog.Warn("audit log insert failed", "action", action, "err", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..91fca7a --- /dev/null +++ b/internal/config/config.go @@ -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) } diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..267cfff --- /dev/null +++ b/internal/config/validate.go @@ -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") diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..795537d --- /dev/null +++ b/internal/config/validate_test.go @@ -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") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..7a9f4e2 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,38 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +type DB struct { + *sql.DB +} + +func Open(path string) (*DB, error) { + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create db dir: %w", err) + } + } + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)", path) + sqlDB, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + sqlDB.SetMaxOpenConns(1) + if err := sqlDB.Ping(); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + return &DB{DB: sqlDB}, nil +} + +func (d *DB) Ping(ctx context.Context) error { return d.DB.PingContext(ctx) } + +func (d *DB) Close() error { return d.DB.Close() } diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..b8e04e9 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,32 @@ +package db + +import ( + "context" + "path/filepath" + "testing" +) + +func TestOpenAndMigrate(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + d, err := Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + defer d.Close() + + if err := d.Migrate(context.Background()); err != nil { + t.Fatalf("migrate: %v", err) + } + // Idempotent. + if err := d.Migrate(context.Background()); err != nil { + t.Fatalf("migrate twice: %v", err) + } + row := d.QueryRow(`SELECT COUNT(*) FROM schema_migrations`) + var n int + if err := row.Scan(&n); err != nil { + t.Fatalf("scan: %v", err) + } + if n < 1 { + t.Fatalf("expected migrations applied, got %d", n) + } +} diff --git a/internal/db/migrate.go b/internal/db/migrate.go new file mode 100644 index 0000000..dec81de --- /dev/null +++ b/internal/db/migrate.go @@ -0,0 +1,114 @@ +package db + +import ( + "context" + "embed" + "fmt" + "io/fs" + "log/slog" + "sort" + "strconv" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type migration struct { + version int + name string + sql string +} + +func (d *DB) Migrate(ctx context.Context) error { + if _, err := d.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + )`); err != nil { + return fmt.Errorf("ensure schema_migrations: %w", err) + } + + migs, err := loadMigrations() + if err != nil { + return err + } + + applied, err := d.appliedVersions(ctx) + if err != nil { + return err + } + + for _, m := range migs { + if applied[m.version] { + continue + } + slog.Info("applying migration", "version", m.version, "name", m.name) + if err := d.applyOne(ctx, m); err != nil { + return fmt.Errorf("apply %d %s: %w", m.version, m.name, err) + } + } + return nil +} + +func (d *DB) applyOne(ctx context.Context, m migration) error { + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, m.sql); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`, + m.version, time.Now().UTC().Format(time.RFC3339)); err != nil { + return err + } + return tx.Commit() +} + +func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) { + rows, err := d.QueryContext(ctx, `SELECT version FROM schema_migrations`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int]bool{} + for rows.Next() { + var v int + if err := rows.Scan(&v); err != nil { + return nil, err + } + out[v] = true + } + return out, rows.Err() +} + +func loadMigrations() ([]migration, error) { + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return nil, fmt.Errorf("read migrations: %w", err) + } + out := make([]migration, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + parts := strings.SplitN(e.Name(), "_", 2) + if len(parts) != 2 { + continue + } + v, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + b, err := migrationsFS.ReadFile("migrations/" + e.Name()) + if err != nil { + return nil, err + } + out = append(out, migration{version: v, name: e.Name(), sql: string(b)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version }) + return out, nil +} diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..ea0c910 --- /dev/null +++ b/internal/db/migrations/0001_init.sql @@ -0,0 +1,73 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pubkey TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + subscription_type TEXT NOT NULL, + expires_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + manual_username INTEGER NOT NULL DEFAULT 0, + last_synced_at TEXT, + expiring_reminder_sent_at TEXT, + deactivated_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_active ON users(is_active); +CREATE INDEX idx_users_expires ON users(expires_at); +CREATE INDEX idx_users_deactivated ON users(deactivated_at); + +CREATE TABLE pending_invoices ( + payment_hash TEXT PRIMARY KEY, + payment_request TEXT NOT NULL, + username TEXT NOT NULL, + pubkey TEXT NOT NULL, + subscription_type TEXT NOT NULL, + years INTEGER NOT NULL DEFAULT 1, + amount_sats INTEGER NOT NULL, + expires_at TEXT NOT NULL, + paid INTEGER NOT NULL DEFAULT 0, + is_renewal INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_pending_unpaid ON pending_invoices(paid, expires_at); +CREATE INDEX idx_pending_username ON pending_invoices(username, paid); + +CREATE TABLE webhook_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + payload TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TEXT, + next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_webhook_outbox_pending ON webhook_outbox(status, next_attempt_at); + +CREATE TABLE dm_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT NOT NULL, + pubkey TEXT NOT NULL, + content TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TEXT, + next_attempt_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + status TEXT NOT NULL DEFAULT 'pending', + last_error TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dm_outbox_pending ON dm_outbox(status, next_attempt_at); +CREATE INDEX idx_dm_outbox_pubkey ON dm_outbox(pubkey, event_type); + +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + actor TEXT NOT NULL, + pubkey TEXT, + details TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/internal/db/migrations/0002_idempotent_target.sql b/internal/db/migrations/0002_idempotent_target.sql new file mode 100644 index 0000000..8c79491 --- /dev/null +++ b/internal/db/migrations/0002_idempotent_target.sql @@ -0,0 +1,9 @@ +-- target_expires_at captures the expiry value computed at first confirmation +-- attempt. Subsequent attempts (e.g. after a crash mid-confirm) read this +-- value back so user mutation stays idempotent. +ALTER TABLE pending_invoices ADD COLUMN target_expires_at TEXT; + +CREATE INDEX idx_audit_pubkey ON audit_log(pubkey); +CREATE INDEX idx_audit_created ON audit_log(created_at); +CREATE INDEX idx_webhook_outbox_status ON webhook_outbox(status, created_at); +CREATE INDEX idx_dm_outbox_status ON dm_outbox(status, created_at); diff --git a/internal/dm/encrypt.go b/internal/dm/encrypt.go new file mode 100644 index 0000000..9855263 --- /dev/null +++ b/internal/dm/encrypt.go @@ -0,0 +1,87 @@ +package dm + +import ( + "context" + "errors" + "fmt" + + gn "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/keyer" + "github.com/nbd-wtf/go-nostr/nip04" + "github.com/nbd-wtf/go-nostr/nip59" +) + +var ErrUnsupportedKind = errors.New("unsupported DM kind") + +// BuildEvent builds the kind-4 or kind-1059 event(s) for a DM. +// For kind 1059 it returns the gift wrap addressed to the recipient. +// For kind 4 it returns a single signed event. +func BuildEvent(kind int, senderSk, recipientHex, content string) ([]gn.Event, error) { + switch kind { + case 4: + return buildNip04(senderSk, recipientHex, content) + case 1059: + return buildNip17(senderSk, recipientHex, content) + default: + return nil, fmt.Errorf("%w: %d", ErrUnsupportedKind, kind) + } +} + +func buildNip04(sk, recipient, content string) ([]gn.Event, error) { + shared, err := nip04.ComputeSharedSecret(recipient, sk) + if err != nil { + return nil, fmt.Errorf("nip04 shared: %w", err) + } + enc, err := nip04.Encrypt(content, shared) + if err != nil { + return nil, fmt.Errorf("nip04 encrypt: %w", err) + } + pk, err := gn.GetPublicKey(sk) + if err != nil { + return nil, err + } + ev := gn.Event{ + PubKey: pk, + CreatedAt: gn.Now(), + Kind: 4, + Tags: gn.Tags{{"p", recipient}}, + Content: enc, + } + if err := ev.Sign(sk); err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + return []gn.Event{ev}, nil +} + +func buildNip17(sk, recipient, content string) ([]gn.Event, error) { + ks, err := keyer.NewPlainKeySigner(sk) + if err != nil { + return nil, fmt.Errorf("keyer: %w", err) + } + ctx := context.Background() + ourPubkey, err := ks.GetPublicKey(ctx) + if err != nil { + return nil, err + } + + rumor := gn.Event{ + Kind: gn.KindDirectMessage, + Content: content, + Tags: gn.Tags{{"p", recipient}}, + CreatedAt: gn.Now(), + PubKey: ourPubkey, + } + rumor.ID = rumor.GetID() + + wrap, err := nip59.GiftWrap( + rumor, + recipient, + func(s string) (string, error) { return ks.Encrypt(ctx, s, recipient) }, + func(e *gn.Event) error { return ks.SignEvent(ctx, e) }, + nil, + ) + if err != nil { + return nil, fmt.Errorf("nip17 giftwrap: %w", err) + } + return []gn.Event{wrap}, nil +} diff --git a/internal/dm/model.go b/internal/dm/model.go new file mode 100644 index 0000000..3332f17 --- /dev/null +++ b/internal/dm/model.go @@ -0,0 +1,33 @@ +package dm + +import "time" + +type EventType string + +const ( + EventWelcome EventType = "welcome" + EventExpiringSoon EventType = "expiring_soon" + EventExpired EventType = "expired" + EventExtended EventType = "extended" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusDelivered Status = "delivered" + StatusDead Status = "dead" +) + +type OutboxItem struct { + ID int64 + EventType EventType + Pubkey string + Content string + Attempts int + LastAttemptAt *time.Time + NextAttemptAt time.Time + Status Status + LastError string + CreatedAt time.Time +} diff --git a/internal/dm/repo.go b/internal/dm/repo.go new file mode 100644 index 0000000..e13cfa2 --- /dev/null +++ b/internal/dm/repo.go @@ -0,0 +1,86 @@ +package dm + +import ( + "context" + "database/sql" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +type Repo struct{ db *db.DB } + +func NewRepo(d *db.DB) *Repo { return &Repo{db: d} } + +func (r *Repo) Insert(ctx context.Context, eventType EventType, pubkey, content string) error { + _, err := r.db.ExecContext(ctx, `INSERT INTO dm_outbox + (event_type, pubkey, content, next_attempt_at) VALUES (?, ?, ?, ?)`, + string(eventType), pubkey, content, time.Now().UTC().Format(time.RFC3339)) + return err +} + +func (r *Repo) Claim(ctx context.Context, limit int) ([]*OutboxItem, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id, event_type, pubkey, content, attempts, + last_attempt_at, next_attempt_at, status, COALESCE(last_error, ''), created_at + FROM dm_outbox + WHERE status = 'pending' AND next_attempt_at <= ? + ORDER BY next_attempt_at ASC LIMIT ?`, + time.Now().UTC().Format(time.RFC3339), limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []*OutboxItem{} + for rows.Next() { + var it OutboxItem + var et, status string + var lastAttempt, nextAttempt, created sql.NullString + if err := rows.Scan(&it.ID, &et, &it.Pubkey, &it.Content, &it.Attempts, + &lastAttempt, &nextAttempt, &status, &it.LastError, &created); err != nil { + return nil, err + } + it.EventType = EventType(et) + it.Status = Status(status) + if lastAttempt.Valid { + if t, err := time.Parse(time.RFC3339, lastAttempt.String); err == nil { + it.LastAttemptAt = &t + } + } + if nextAttempt.Valid { + if t, err := time.Parse(time.RFC3339, nextAttempt.String); err == nil { + it.NextAttemptAt = t + } + } + if created.Valid { + if t, err := time.Parse(time.RFC3339, created.String); err == nil { + it.CreatedAt = t + } else if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil { + it.CreatedAt = t + } + } + out = append(out, &it) + } + return out, rows.Err() +} + +func (r *Repo) MarkDelivered(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'delivered', + last_attempt_at = ?, last_error = '' WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), id) + return err +} + +func (r *Repo) MarkRetry(ctx context.Context, id int64, attempts int, nextAt time.Time, errMsg string) error { + _, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET attempts = ?, + last_attempt_at = ?, next_attempt_at = ?, last_error = ? WHERE id = ?`, + attempts, time.Now().UTC().Format(time.RFC3339), + nextAt.UTC().Format(time.RFC3339), errMsg, id) + return err +} + +func (r *Repo) MarkDead(ctx context.Context, id int64, errMsg string) error { + _, err := r.db.ExecContext(ctx, `UPDATE dm_outbox SET status = 'dead', + last_attempt_at = ?, last_error = ? WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), errMsg, id) + return err +} diff --git a/internal/dm/service.go b/internal/dm/service.go new file mode 100644 index 0000000..b54c75b --- /dev/null +++ b/internal/dm/service.go @@ -0,0 +1,35 @@ +package dm + +import ( + "context" + + "github.com/noderunners/nip05api/internal/messages" +) + +type Service struct { + repo *Repo + templates *messages.Templates + enabled bool +} + +func NewService(repo *Repo, t *messages.Templates, enabled bool) *Service { + return &Service{repo: repo, templates: t, enabled: enabled} +} + +func (s *Service) Enabled() bool { return s.enabled } + +// Send renders the template for the given event and enqueues a DM. Empty rendered +// content short-circuits and returns nil. +func (s *Service) Send(ctx context.Context, event EventType, pubkey string, vars map[string]string) error { + if !s.enabled { + return nil + } + content, err := s.templates.Render(string(event), vars) + if err != nil { + return err + } + if content == "" { + return nil + } + return s.repo.Insert(ctx, event, pubkey, content) +} diff --git a/internal/dm/worker.go b/internal/dm/worker.go new file mode 100644 index 0000000..2b457a0 --- /dev/null +++ b/internal/dm/worker.go @@ -0,0 +1,109 @@ +package dm + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/noderunners/nip05api/internal/nostr" +) + +var retrySchedule = []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 30 * time.Minute, + 2 * time.Hour, + 12 * time.Hour, +} + +type Worker struct { + repo *Repo + pool *nostr.Pool + senderSk string + kind int + maxRetries int +} + +func NewWorker(repo *Repo, pool *nostr.Pool, nsec string, kind int) (*Worker, error) { + sk, err := nostr.NsecToHex(nsec) + if err != nil { + return nil, err + } + return &Worker{ + repo: repo, + pool: pool, + senderSk: sk, + kind: kind, + maxRetries: len(retrySchedule), + }, nil +} + +func (w *Worker) Run(ctx context.Context) { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.tick(ctx) + } + } +} + +func (w *Worker) tick(ctx context.Context) { + items, err := w.repo.Claim(ctx, 5) + if err != nil { + slog.Error("dm claim", "err", err) + return + } + if len(items) == 0 { + return + } + var wg sync.WaitGroup + for _, it := range items { + wg.Add(1) + go func(it *OutboxItem) { + defer wg.Done() + w.deliver(ctx, it) + }(it) + } + wg.Wait() +} + +func (w *Worker) deliver(ctx context.Context, it *OutboxItem) { + pubCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + events, err := BuildEvent(w.kind, w.senderSk, it.Pubkey, it.Content) + if err != nil { + w.handleErr(ctx, it, err) + return + } + for _, ev := range events { + ev := ev + if err := nostr.Publish(pubCtx, w.pool, &ev); err != nil { + w.handleErr(ctx, it, err) + return + } + } + _ = w.repo.MarkDelivered(ctx, it.ID) + slog.Info("dm delivered", "event", it.EventType, "pubkey", it.Pubkey, "id", it.ID) +} + +func (w *Worker) handleErr(ctx context.Context, it *OutboxItem, err error) { + attempts := it.Attempts + 1 + if attempts >= w.maxRetries { + _ = w.repo.MarkDead(ctx, it.ID, err.Error()) + slog.Error("dm dead", "id", it.ID, "pubkey", it.Pubkey, "err", err) + return + } + idx := attempts - 1 + if idx >= len(retrySchedule) { + idx = len(retrySchedule) - 1 + } + next := time.Now().UTC().Add(retrySchedule[idx]) + _ = w.repo.MarkRetry(ctx, it.ID, attempts, next, err.Error()) + slog.Warn("dm retry", "id", it.ID, "attempts", attempts, "err", err) +} diff --git a/internal/expiry/worker.go b/internal/expiry/worker.go new file mode 100644 index 0000000..d900385 --- /dev/null +++ b/internal/expiry/worker.go @@ -0,0 +1,175 @@ +package expiry + +import ( + "context" + "log/slog" + "strconv" + "time" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +type Worker struct { + users *user.Service + dms *dm.Service + hooks *webhook.Service + audit *audit.Logger + domain string + frontend string + graceDays int + reminderDays []int + cronHourUTC int + clock func() time.Time +} + +func NewWorker(users *user.Service, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger, + domain, frontend string, graceDays int, reminderDays []int, cronHourUTC int) *Worker { + if len(reminderDays) == 0 { + reminderDays = []int{7} + } + return &Worker{ + users: users, + dms: dms, + hooks: hooks, + audit: aud, + domain: domain, + frontend: frontend, + graceDays: graceDays, + reminderDays: reminderDays, + cronHourUTC: cronHourUTC, + clock: func() time.Time { return time.Now().UTC() }, + } +} + +func (w *Worker) WithClock(c func() time.Time) *Worker { w.clock = c; return w } + +func (w *Worker) Run(ctx context.Context) { + w.RunOnce(ctx) + for { + next := w.nextRun() + timer := time.NewTimer(time.Until(next)) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + w.RunOnce(ctx) + } + } +} + +func (w *Worker) nextRun() time.Time { + now := w.clock() + target := time.Date(now.Year(), now.Month(), now.Day(), w.cronHourUTC, 0, 0, 0, time.UTC) + if !target.After(now) { + target = target.Add(24 * time.Hour) + } + return target +} + +func (w *Worker) RunOnce(ctx context.Context) { + now := w.clock() + w.processReminders(ctx, now) + w.processExpirations(ctx, now) + w.processGraceCleanup(ctx, now) +} + +func (w *Worker) processReminders(ctx context.Context, now time.Time) { + for _, days := range w.reminderDays { + users, err := w.users.Repo().ListPendingReminders(ctx, days, now) + if err != nil { + slog.Error("expiry reminders list", "err", err) + continue + } + for _, u := range users { + vars := buildVars(u, w.domain, w.frontend, days) + if err := w.dms.Send(ctx, dm.EventExpiringSoon, u.Pubkey, vars); err != nil { + slog.Error("expiry reminder dm", "pubkey", u.Pubkey, "err", err) + continue + } + ts := now + u.ExpiringReminderSentAt = &ts + if err := w.users.Repo().Update(ctx, u); err != nil { + slog.Error("expiry reminder update", "pubkey", u.Pubkey, "err", err) + } + } + } +} + +func (w *Worker) processExpirations(ctx context.Context, now time.Time) { + users, err := w.users.Repo().ListExpired(ctx, now) + if err != nil { + slog.Error("expiry list", "err", err) + return + } + for _, u := range users { + ts := now + u.IsActive = false + u.DeactivatedAt = &ts + if err := w.users.Repo().Update(ctx, u); err != nil { + slog.Error("expiry deactivate", "pubkey", u.Pubkey, "err", err) + continue + } + vars := buildVars(u, w.domain, w.frontend, 0) + vars["grace_days"] = strconv.Itoa(w.graceDays) + _ = w.dms.Send(ctx, dm.EventExpired, u.Pubkey, vars) + _ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "expired")) + w.audit.Log(ctx, audit.ActionUserExpired, audit.ActorSystem, u.Pubkey, map[string]any{ + "username": u.Username, + }) + } +} + +func (w *Worker) processGraceCleanup(ctx context.Context, now time.Time) { + cutoff := now.Add(-time.Duration(w.graceDays) * 24 * time.Hour) + users, err := w.users.Repo().ListGraceExpired(ctx, cutoff) + if err != nil { + slog.Error("grace list", "err", err) + return + } + for _, u := range users { + if err := w.users.Repo().Delete(ctx, u.Pubkey); err != nil { + slog.Error("grace delete", "pubkey", u.Pubkey, "err", err) + continue + } + _ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "grace_cleanup")) + w.audit.Log(ctx, audit.ActionUserGracePurged, audit.ActorSystem, u.Pubkey, map[string]any{ + "username": u.Username, + }) + } +} + +func buildVars(u *user.User, domain, frontend string, daysRemaining int) map[string]string { + expires := "lifetime" + if u.ExpiresAt != nil { + expires = u.ExpiresAt.Format("2006-01-02") + } + return map[string]string{ + "username": u.Username, + "npub": nostr.HexToNpub(u.Pubkey), + "pubkey": u.Pubkey, + "domain": domain, + "expires_at": expires, + "days_remaining": strconv.Itoa(daysRemaining), + "frontend_url": frontend, + "subscription_type": string(u.SubscriptionType), + } +} + +func hookData(u *user.User, reason string) map[string]any { + d := map[string]any{ + "pubkey": u.Pubkey, + "npub": nostr.HexToNpub(u.Pubkey), + "username": u.Username, + "subscription_type": string(u.SubscriptionType), + "reason": reason, + } + if u.ExpiresAt != nil { + d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } + return d +} diff --git a/internal/expiry/worker_test.go b/internal/expiry/worker_test.go new file mode 100644 index 0000000..14b7573 --- /dev/null +++ b/internal/expiry/worker_test.go @@ -0,0 +1,204 @@ +package expiry + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/messages" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + +func newTestStack(t *testing.T) (*db.DB, *user.Service, *dm.Service, *webhook.Service, *audit.Logger) { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + + tmpls, _ := messages.Load("/nonexistent.yaml") + users := user.NewService(user.NewRepo(d), nil) + dms := dm.NewService(dm.NewRepo(d), tmpls, true) + hooks := webhook.NewService(webhook.NewRepo(d), "test.local", true) + return d, users, dms, hooks, audit.New(d) +} + +func TestExpiry_Reminder(t *testing.T) { + d, users, dms, hooks, aud := newTestStack(t) + now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + expires := now.Add(7 * 24 * time.Hour) + + u := &user.User{ + Pubkey: testHex, + Username: "alice", + SubscriptionType: user.SubYearly, + ExpiresAt: &expires, + IsActive: true, + } + if err := users.Repo().Insert(context.Background(), u); err != nil { + t.Fatal(err) + } + + w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", + 30, []int{7}, 9).WithClock(func() time.Time { return now }) + w.RunOnce(context.Background()) + + var dmCount int + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil { + t.Fatal(err) + } + if dmCount != 1 { + t.Errorf("expected 1 expiring_soon DM, got %d", dmCount) + } + + // Reminder flag should be set, so a re-run does not double-send. + w.RunOnce(context.Background()) + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil { + t.Fatal(err) + } + if dmCount != 1 { + t.Errorf("re-run should not double-fire, got %d", dmCount) + } +} + +func TestExpiry_Expiration(t *testing.T) { + d, users, dms, hooks, aud := newTestStack(t) + now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + expired := now.Add(-1 * time.Hour) + + u := &user.User{ + Pubkey: testHex, + Username: "bob", + SubscriptionType: user.SubYearly, + ExpiresAt: &expired, + IsActive: true, + } + if err := users.Repo().Insert(context.Background(), u); err != nil { + t.Fatal(err) + } + + w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", + 30, []int{7}, 9).WithClock(func() time.Time { return now }) + w.RunOnce(context.Background()) + + got, err := users.Repo().GetByPubkey(context.Background(), testHex) + if err != nil { + t.Fatal(err) + } + if got.IsActive { + t.Error("expected user deactivated") + } + if got.DeactivatedAt == nil { + t.Error("expected deactivated_at set") + } + + var dmCount int + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expired'`).Scan(&dmCount); err != nil { + t.Fatal(err) + } + if dmCount != 1 { + t.Errorf("expected 1 expired DM, got %d", dmCount) + } + + var hookCount int + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM webhook_outbox WHERE event_type = 'user.removed'`).Scan(&hookCount); err != nil { + t.Fatal(err) + } + if hookCount != 1 { + t.Errorf("expected 1 user.removed webhook, got %d", hookCount) + } +} + +func TestExpiry_GraceCleanup(t *testing.T) { + d, users, dms, hooks, aud := newTestStack(t) + now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + deactivated := now.Add(-31 * 24 * time.Hour) + expired := now.Add(-32 * 24 * time.Hour) + + u := &user.User{ + Pubkey: testHex, + Username: "charlie", + SubscriptionType: user.SubYearly, + ExpiresAt: &expired, + IsActive: false, + DeactivatedAt: &deactivated, + } + if err := users.Repo().Insert(context.Background(), u); err != nil { + t.Fatal(err) + } + // Insert sets is_active=1 by default; force it inactive. + if err := users.Repo().Update(context.Background(), u); err != nil { + t.Fatal(err) + } + + w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", + 30, []int{7}, 9).WithClock(func() time.Time { return now }) + w.RunOnce(context.Background()) + + // User should be hard-deleted. + _, err := users.Repo().GetByPubkey(context.Background(), testHex) + if err == nil { + t.Error("expected user deleted after grace cleanup") + } + + // Audit log should record the purge. + var auditCount int + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM audit_log WHERE action = 'user.grace_purged'`).Scan(&auditCount); err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Errorf("expected 1 grace_purged audit, got %d", auditCount) + } +} + +func TestExpiry_LifetimeUserNotAffected(t *testing.T) { + d, users, dms, hooks, aud := newTestStack(t) + now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + + u := &user.User{ + Pubkey: testHex, + Username: "lifetime", + SubscriptionType: user.SubLifetime, + IsActive: true, + } + if err := users.Repo().Insert(context.Background(), u); err != nil { + t.Fatal(err) + } + + w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local", + 30, []int{7}, 9).WithClock(func() time.Time { return now }) + w.RunOnce(context.Background()) + + got, err := users.Repo().GetByPubkey(context.Background(), testHex) + if err != nil { + t.Fatal(err) + } + if !got.IsActive { + t.Error("lifetime user should not be deactivated") + } + + var dmCount int + if err := d.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM dm_outbox`).Scan(&dmCount); err != nil { + t.Fatal(err) + } + if dmCount != 0 { + t.Errorf("lifetime user should receive no DM, got %d", dmCount) + } +} diff --git a/internal/http/docs/docs.go b/internal/http/docs/docs.go new file mode 100644 index 0000000..b33ba7c --- /dev/null +++ b/internal/http/docs/docs.go @@ -0,0 +1,93 @@ +package docs + +import ( + _ "embed" + "encoding/json" + "net/http" + + "gopkg.in/yaml.v3" +) + +//go:embed openapi.yaml +var openapiYAML []byte + +var openapiJSON []byte + +func init() { + var raw any + if err := yaml.Unmarshal(openapiYAML, &raw); err != nil { + panic(err) + } + clean := convertMaps(raw) + b, err := json.Marshal(clean) + if err != nil { + panic(err) + } + openapiJSON = b +} + +// convertMaps recursively converts map[interface{}]interface{} to map[string]interface{}. +func convertMaps(in any) any { + switch v := in.(type) { + case map[any]any: + m := make(map[string]any, len(v)) + for k, val := range v { + m[toString(k)] = convertMaps(val) + } + return m + case map[string]any: + m := make(map[string]any, len(v)) + for k, val := range v { + m[k] = convertMaps(val) + } + return m + case []any: + out := make([]any, len(v)) + for i, item := range v { + out[i] = convertMaps(item) + } + return out + default: + return v + } +} + +func toString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func ServeJSON(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(openapiJSON) +} + +const swaggerHTML = ` + + + NIP-05 API + + + + +
+ + + +` + +func ServeUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(swaggerHTML)) +} diff --git a/internal/http/docs/openapi.yaml b/internal/http/docs/openapi.yaml new file mode 100644 index 0000000..d26c9a3 --- /dev/null +++ b/internal/http/docs/openapi.yaml @@ -0,0 +1,297 @@ +openapi: 3.1.0 +info: + title: NIP-05 API + description: Single-domain NIP-05 identity service with Lightning-paid registration. + version: 1.0.0 +servers: + - url: / +tags: + - name: Public + description: Anonymous, infrastructure-level endpoints. Cacheable, no auth. + - name: User + description: Anonymous flows for end users — lookup, availability, payment. + - name: Admin + description: Privileged operations. Require an `X-API-Key` header. +components: + securitySchemes: + AdminAPIKey: + type: apiKey + in: header + name: X-API-Key + schemas: + Error: + type: object + properties: + error: { type: string } + detail: { type: string } + Pricing: + type: object + properties: + yearly_sats: { type: integer } + lifetime_sats: { type: integer } + lightning_enabled: { type: boolean } + Invoice: + type: object + properties: + payment_hash: { type: string } + payment_request: { type: string } + amount_sats: { type: integer } + expires_at: { type: string, format: date-time } + username: { type: string } + is_renewal: { type: boolean } + InvoiceStatus: + type: object + properties: + payment_hash: { type: string } + status: { type: string, enum: [pending, paid, expired] } + username: { type: string } + User: + type: object + properties: + pubkey: { type: string } + npub: { type: string } + username: { type: string } + subscription_type: { type: string, enum: [yearly, lifetime] } + is_active: { type: boolean } + expires_at: { type: string, format: date-time, nullable: true } + deactivated_at: { type: string, format: date-time, nullable: true } + UserLookup: + type: object + properties: + pubkey: { type: string } + npub: { type: string } + is_whitelisted: { type: boolean } + username: { type: string } + expires_at: { type: string, format: date-time, nullable: true } + in_grace: { type: boolean } + reserved_username: { type: string } + expired_at: { type: string, format: date-time } +paths: + /.well-known/nostr.json: + get: + tags: [Public] + summary: NIP-05 lookup + parameters: + - in: query + name: name + schema: { type: string } + responses: + '200': + description: NIP-05 names map + content: + application/json: + schema: + type: object + properties: + names: + type: object + additionalProperties: { type: string } + relays: + type: object + additionalProperties: + type: array + items: { type: string } + /healthz: + get: + tags: [Public] + summary: Health check + responses: + '200': { description: OK } + '503': { description: Down } + /v1/pricing: + get: + tags: [Public] + summary: Pricing info + responses: + '200': + description: Pricing + content: + application/json: + schema: { $ref: '#/components/schemas/Pricing' } + /v1/invoices: + post: + tags: [User] + summary: Create payment invoice + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pubkey] + properties: + pubkey: { type: string, description: "Hex pubkey or npub" } + username: + type: string + description: "Optional. Auto-generated from the pubkey if omitted." + subscription_type: + type: string + enum: [yearly, lifetime] + description: "Optional. Defaults to lifetime." + years: + type: integer + minimum: 1 + description: "Optional. Defaults to 1 when subscription_type is yearly; ignored for lifetime." + responses: + '200': + description: Invoice + content: + application/json: + schema: { $ref: '#/components/schemas/Invoice' } + '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '403': { description: Forbidden — user already has lifetime access, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '409': { description: Conflict — username unavailable or pending invoice already exists, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + '503': { description: Lightning unavailable, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } + /v1/invoices/{payment_hash}: + get: + tags: [User] + summary: Invoice status + parameters: + - in: path + name: payment_hash + required: true + schema: { type: string } + responses: + '200': + description: Status + content: + application/json: + schema: { $ref: '#/components/schemas/InvoiceStatus' } + '404': { description: Not found } + /v1/users/{pubkey}: + get: + tags: [User] + summary: Lookup user by pubkey (npub or hex) + parameters: + - in: path + name: pubkey + required: true + schema: { type: string } + responses: + '200': + description: User lookup + content: + application/json: + schema: { $ref: '#/components/schemas/UserLookup' } + '404': { description: Never registered } + /v1/usernames/{name}/available: + get: + tags: [User] + summary: Username availability + parameters: + - in: path + name: name + required: true + schema: { type: string } + responses: + '200': + description: Availability + content: + application/json: + schema: + type: object + properties: + username: { type: string } + available: { type: boolean } + /v1/admin/users: + post: + tags: [Admin] + summary: Add user (admin) + security: [{ AdminAPIKey: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [pubkey, username, subscription_type] + properties: + pubkey: { type: string } + username: { type: string } + subscription_type: { type: string, enum: [yearly, lifetime] } + years: { type: integer } + responses: + '201': + description: Created + content: { application/json: { schema: { $ref: '#/components/schemas/User' } } } + '401': { description: Unauthorized } + get: + tags: [Admin] + summary: List users (admin) + security: [{ AdminAPIKey: [] }] + parameters: + - in: query + name: active + schema: { type: boolean } + - in: query + name: q + schema: { type: string } + responses: + '200': + description: User list + content: + application/json: + schema: + type: array + items: { $ref: '#/components/schemas/User' } + '401': { description: Unauthorized } + /v1/admin/users/{pubkey}: + put: + tags: [Admin] + summary: Update username (admin) + security: [{ AdminAPIKey: [] }] + parameters: + - in: path + name: pubkey + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username] + properties: + username: { type: string } + responses: + '200': { description: Updated } + '401': { description: Unauthorized } + '404': { description: Not found } + '409': { description: Conflict } + delete: + tags: [Admin] + summary: Delete user (admin) + security: [{ AdminAPIKey: [] }] + parameters: + - in: path + name: pubkey + required: true + schema: { type: string } + responses: + '200': { description: Deleted } + '401': { description: Unauthorized } + '404': { description: Not found } + /v1/admin/users/{pubkey}/extend: + post: + tags: [Admin] + summary: Extend subscription (admin) + security: [{ AdminAPIKey: [] }] + parameters: + - in: path + name: pubkey + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + years: { type: integer } + subscription_type: { type: string, enum: [yearly, lifetime] } + responses: + '200': { description: Extended } + '401': { description: Unauthorized } + '404': { description: Not found } diff --git a/internal/http/handlers/admin_extend.go b/internal/http/handlers/admin_extend.go new file mode 100644 index 0000000..8c63bd1 --- /dev/null +++ b/internal/http/handlers/admin_extend.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +type AdminExtend struct { + Users *user.Service + DMs *dm.Service + Hooks *webhook.Service + Audit *audit.Logger + Domain string + Frontend string +} + +type extendReq struct { + Years int `json:"years"` + SubscriptionType string `json:"subscription_type"` +} + +func (h *AdminExtend) Handle(w http.ResponseWriter, r *http.Request) { + hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + var body extendReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") + return + } + + u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk) + if errors.Is(err, user.ErrUserNotFound) { + WriteError(w, http.StatusNotFound, "NotFound", "user not found") + return + } + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + + sub := u.SubscriptionType + if body.SubscriptionType != "" { + s := user.SubscriptionType(body.SubscriptionType) + if !s.Valid() { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type") + return + } + sub = s + } + years := body.Years + if sub == user.SubYearly && years <= 0 { + years = 1 + } + + if err := h.Users.Renew(r.Context(), u, sub, years); err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + + vars := dmVars(u, h.Domain, h.Frontend) + _ = h.DMs.Send(r.Context(), dm.EventExtended, u.Pubkey, vars) + _ = h.Hooks.Enqueue(r.Context(), webhook.EventUserExtended, hookData(u, h.Domain)) + h.Audit.Log(r.Context(), audit.ActionUserExtended, audit.ActorAdmin, u.Pubkey, map[string]any{ + "subscription_type": string(sub), + "years": years, + }) + + WriteJSON(w, http.StatusOK, userResponse(u)) +} diff --git a/internal/http/handlers/admin_helpers.go b/internal/http/handlers/admin_helpers.go new file mode 100644 index 0000000..027b713 --- /dev/null +++ b/internal/http/handlers/admin_helpers.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "time" + + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" +) + +func userResponse(u *user.User) map[string]any { + resp := map[string]any{ + "pubkey": u.Pubkey, + "npub": nostr.HexToNpub(u.Pubkey), + "username": u.Username, + "subscription_type": string(u.SubscriptionType), + "is_active": u.IsActive, + "manual_username": u.ManualUsername, + "created_at": u.CreatedAt.UTC().Format(time.RFC3339), + } + if u.ExpiresAt != nil { + resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } + if u.DeactivatedAt != nil { + resp["deactivated_at"] = u.DeactivatedAt.UTC().Format(time.RFC3339) + } + return resp +} + +func dmVars(u *user.User, domain, frontend string) map[string]string { + expires := "lifetime" + if u.ExpiresAt != nil { + expires = u.ExpiresAt.Format("2006-01-02") + } + return map[string]string{ + "username": u.Username, + "npub": nostr.HexToNpub(u.Pubkey), + "pubkey": u.Pubkey, + "domain": domain, + "expires_at": expires, + "days_remaining": "", + "frontend_url": frontend, + "subscription_type": string(u.SubscriptionType), + } +} + +func hookData(u *user.User, domain string) map[string]any { + d := map[string]any{ + "pubkey": u.Pubkey, + "npub": nostr.HexToNpub(u.Pubkey), + "username": u.Username, + "subscription_type": string(u.SubscriptionType), + } + if u.ExpiresAt != nil { + d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } + return d +} diff --git a/internal/http/handlers/admin_users.go b/internal/http/handlers/admin_users.go new file mode 100644 index 0000000..1f5b1d9 --- /dev/null +++ b/internal/http/handlers/admin_users.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +type AdminUsers struct { + Users *user.Service + DMs *dm.Service + Hooks *webhook.Service + Audit *audit.Logger + Domain string + Frontend string +} + +type adminAddReq struct { + Pubkey string `json:"pubkey"` + Username string `json:"username"` + SubscriptionType string `json:"subscription_type"` + Years int `json:"years"` +} + +func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) { + var body adminAddReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") + return + } + hexpk, err := nostr.NormalizePubkey(body.Pubkey) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + sub := user.SubscriptionType(body.SubscriptionType) + if !sub.Valid() { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type") + return + } + years := body.Years + if years <= 0 { + years = 1 + } + + if existing, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk); err == nil && existing != nil { + WriteError(w, http.StatusConflict, "Conflict", "user already exists") + return + } + if existing, err := h.Users.Repo().GetByUsername(r.Context(), user.NormalizeUsername(body.Username)); err == nil && existing != nil { + WriteError(w, http.StatusConflict, "Conflict", "username taken") + return + } + + u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, true) + if err != nil { + if errors.Is(err, user.ErrInvalidUsername) { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username") + return + } + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + + vars := dmVars(u, h.Domain, h.Frontend) + _ = h.DMs.Send(r.Context(), dm.EventWelcome, u.Pubkey, vars) + _ = h.Hooks.Enqueue(r.Context(), webhook.EventUserAdded, hookData(u, h.Domain)) + h.Audit.Log(r.Context(), audit.ActionUserAdded, audit.ActorAdmin, u.Pubkey, map[string]any{ + "username": u.Username, + "subscription_type": string(u.SubscriptionType), + "years": years, + }) + + WriteJSON(w, http.StatusCreated, userResponse(u)) +} + +func (h *AdminUsers) List(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + activeOnly := q.Get("active") == "true" + limit, _ := strconv.Atoi(q.Get("limit")) + if limit <= 0 { + limit = 100 + } + users, err := h.Users.Repo().List(r.Context(), user.ListFilter{ + ActiveOnly: activeOnly, + Search: q.Get("q"), + Limit: limit, + }) + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + out := make([]map[string]any, 0, len(users)) + for _, u := range users { + out = append(out, userResponse(u)) + } + WriteJSON(w, http.StatusOK, out) +} + +func (h *AdminUsers) Update(w http.ResponseWriter, r *http.Request) { + hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + var body struct { + Username string `json:"username"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") + return + } + u, err := h.Users.SetUsername(r.Context(), hexpk, body.Username) + if err != nil { + switch { + case errors.Is(err, user.ErrUserNotFound): + WriteError(w, http.StatusNotFound, "NotFound", "user not found") + case errors.Is(err, user.ErrInvalidUsername): + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username") + case errors.Is(err, user.ErrUsernameTaken): + WriteError(w, http.StatusConflict, "Conflict", "username taken") + default: + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + } + return + } + h.Audit.Log(r.Context(), audit.ActionUserUsernameChanged, audit.ActorAdmin, u.Pubkey, map[string]any{ + "username": u.Username, + }) + WriteJSON(w, http.StatusOK, userResponse(u)) +} + +func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) { + hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk) + if errors.Is(err, user.ErrUserNotFound) { + WriteError(w, http.StatusNotFound, "NotFound", "user not found") + return + } + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + if err := h.Users.Delete(r.Context(), hexpk); err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + _ = h.Hooks.Enqueue(r.Context(), webhook.EventUserRemoved, hookData(u, h.Domain)) + h.Audit.Log(r.Context(), audit.ActionUserDeleted, audit.ActorAdmin, u.Pubkey, map[string]any{ + "username": u.Username, + }) + WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true}) +} + diff --git a/internal/http/handlers/health.go b/internal/http/handlers/health.go new file mode 100644 index 0000000..f637ef5 --- /dev/null +++ b/internal/http/handlers/health.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/noderunners/nip05api/internal/db" +) + +type Health struct { + DB *db.DB + Version string +} + +func (h *Health) Handle(w http.ResponseWriter, r *http.Request) { + if err := h.DB.Ping(r.Context()); err != nil { + WriteJSON(w, http.StatusServiceUnavailable, map[string]string{ + "status": "down", + "version": h.Version, + }) + return + } + WriteJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "version": h.Version, + }) +} diff --git a/internal/http/handlers/invoices.go b/internal/http/handlers/invoices.go new file mode 100644 index 0000000..34732cd --- /dev/null +++ b/internal/http/handlers/invoices.go @@ -0,0 +1,109 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" +) + +type Invoices struct { + Service *invoice.Service + LightningEnabled bool +} + +type createInvoiceReq struct { + Username string `json:"username"` + Pubkey string `json:"pubkey"` + SubscriptionType string `json:"subscription_type"` + Years int `json:"years"` +} + +func (h *Invoices) Create(w http.ResponseWriter, r *http.Request) { + if !h.LightningEnabled { + WriteError(w, http.StatusServiceUnavailable, "LightningDisabled", "lightning payments are disabled") + return + } + var body createInvoiceReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") + return + } + hexpk, err := nostr.NormalizePubkey(body.Pubkey) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + subStr := strings.TrimSpace(body.SubscriptionType) + if subStr == "" { + subStr = string(user.SubLifetime) + } + sub := user.SubscriptionType(subStr) + if !sub.Valid() { + WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type") + return + } + years := body.Years + if sub == user.SubYearly && years <= 0 { + years = 1 + } + p, err := h.Service.Create(r.Context(), invoice.CreateRequest{ + Username: body.Username, + Pubkey: hexpk, + SubscriptionType: sub, + Years: years, + }) + if err != nil { + switch { + case errors.Is(err, invoice.ErrLifetimeAccess): + WriteError(w, http.StatusForbidden, "User already has lifetime access", "") + case errors.Is(err, invoice.ErrPendingInvoiceExists): + WriteError(w, http.StatusConflict, "Conflict", err.Error()) + case errors.Is(err, invoice.ErrUsernameTaken), + errors.Is(err, user.ErrUsernameTaken): + WriteError(w, http.StatusConflict, "Conflict", "username unavailable") + case errors.Is(err, invoice.ErrUsernameMismatch): + WriteError(w, http.StatusConflict, "Conflict", err.Error()) + case errors.Is(err, user.ErrInvalidUsername), + errors.Is(err, invoice.ErrInvalidYears): + WriteError(w, http.StatusBadRequest, "ValidationError", err.Error()) + case errors.Is(err, invoice.ErrLNbits): + WriteError(w, http.StatusServiceUnavailable, "LightningError", err.Error()) + default: + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + } + return + } + WriteJSON(w, http.StatusOK, map[string]any{ + "payment_hash": p.PaymentHash, + "payment_request": p.PaymentRequest, + "amount_sats": p.AmountSats, + "expires_at": p.ExpiresAt.UTC().Format(time.RFC3339), + "username": p.Username, + "is_renewal": p.IsRenewal, + }) +} + +func (h *Invoices) Get(w http.ResponseWriter, r *http.Request) { + hash := chi.URLParam(r, "payment_hash") + p, err := h.Service.Repo().Get(r.Context(), hash) + if errors.Is(err, invoice.ErrInvoiceNotFound) { + WriteError(w, http.StatusNotFound, "NotFound", "invoice not found") + return + } + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + WriteJSON(w, http.StatusOK, map[string]any{ + "payment_hash": p.PaymentHash, + "status": string(p.Status()), + "username": p.Username, + }) +} diff --git a/internal/http/handlers/nostrjson.go b/internal/http/handlers/nostrjson.go new file mode 100644 index 0000000..a03a3f6 --- /dev/null +++ b/internal/http/handlers/nostrjson.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "net/http" + + "github.com/noderunners/nip05api/internal/user" +) + +type NostrJSON struct { + Users *user.Service + Relays []string +} + +func (h *NostrJSON) Handle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=60") + + names, err := h.Users.Repo().ActiveByName(r.Context()) + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + + if q := r.URL.Query().Get("name"); q != "" { + filtered := map[string]string{} + if pk, ok := names[q]; ok { + filtered[q] = pk + } + names = filtered + } + + relays := map[string][]string{} + if len(h.Relays) > 0 { + for _, pk := range names { + relays[pk] = h.Relays + } + } + WriteJSON(w, http.StatusOK, map[string]any{ + "names": names, + "relays": relays, + }) +} diff --git a/internal/http/handlers/pricing.go b/internal/http/handlers/pricing.go new file mode 100644 index 0000000..ef7628e --- /dev/null +++ b/internal/http/handlers/pricing.go @@ -0,0 +1,17 @@ +package handlers + +import "net/http" + +type Pricing struct { + YearlySats int64 + LifetimeSats int64 + LightningEnabled bool +} + +func (h *Pricing) Handle(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, map[string]any{ + "yearly_sats": h.YearlySats, + "lifetime_sats": h.LifetimeSats, + "lightning_enabled": h.LightningEnabled, + }) +} diff --git a/internal/http/handlers/respond.go b/internal/http/handlers/respond.go new file mode 100644 index 0000000..2068caf --- /dev/null +++ b/internal/http/handlers/respond.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +func WriteJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if body == nil { + return + } + _ = json.NewEncoder(w).Encode(body) +} + +func WriteError(w http.ResponseWriter, code int, kind, detail string) { + WriteJSON(w, code, map[string]string{"error": kind, "detail": detail}) +} diff --git a/internal/http/handlers/usernames.go b/internal/http/handlers/usernames.go new file mode 100644 index 0000000..c6f7654 --- /dev/null +++ b/internal/http/handlers/usernames.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/user" +) + +type Usernames struct{ Users *user.Service } + +func (h *Usernames) Available(w http.ResponseWriter, r *http.Request) { + name := user.NormalizeUsername(chi.URLParam(r, "name")) + if err := user.ValidateUsername(name, h.Users.Reserved()); err != nil { + WriteJSON(w, http.StatusOK, map[string]any{ + "username": name, + "available": false, + "reason": "invalid_or_reserved", + }) + return + } + avail, err := h.Users.IsAvailable(r.Context(), name) + if err != nil { + if errors.Is(err, user.ErrInvalidUsername) { + WriteJSON(w, http.StatusOK, map[string]any{ + "username": name, + "available": false, + "reason": "invalid", + }) + return + } + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + WriteJSON(w, http.StatusOK, map[string]any{ + "username": name, + "available": avail, + }) +} diff --git a/internal/http/handlers/users.go b/internal/http/handlers/users.go new file mode 100644 index 0000000..1e0b6c4 --- /dev/null +++ b/internal/http/handlers/users.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" +) + +type Users struct { + Users *user.Service + GraceDays int +} + +func (h *Users) Get(w http.ResponseWriter, r *http.Request) { + hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) + if err != nil { + WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") + return + } + u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk) + if errors.Is(err, user.ErrUserNotFound) { + WriteError(w, http.StatusNotFound, "NotFound", "user not registered") + return + } + if err != nil { + WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) + return + } + + resp := map[string]any{ + "pubkey": u.Pubkey, + "npub": nostr.HexToNpub(u.Pubkey), + } + if u.IsActive { + resp["is_whitelisted"] = true + resp["username"] = u.Username + if u.ExpiresAt != nil { + resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } else { + resp["expires_at"] = nil + } + resp["subscription_type"] = string(u.SubscriptionType) + } else { + resp["is_whitelisted"] = false + if u.ExpiresAt != nil { + resp["expired_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } + if u.DeactivatedAt != nil { + cutoff := u.DeactivatedAt.Add(time.Duration(h.GraceDays) * 24 * time.Hour) + if time.Now().UTC().Before(cutoff) { + resp["in_grace"] = true + resp["reserved_username"] = u.Username + } else { + resp["in_grace"] = false + } + } + } + WriteJSON(w, http.StatusOK, resp) +} diff --git a/internal/http/middleware/adminauth.go b/internal/http/middleware/adminauth.go new file mode 100644 index 0000000..3b7adc8 --- /dev/null +++ b/internal/http/middleware/adminauth.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "crypto/subtle" + "encoding/json" + "net/http" +) + +func AdminAuth(apiKey string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + provided := r.Header.Get("X-API-Key") + if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(apiKey)) != 1 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "Unauthorized", + "detail": "missing or invalid X-API-Key", + }) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/middleware/bodylimit.go b/internal/http/middleware/bodylimit.go new file mode 100644 index 0000000..cba3112 --- /dev/null +++ b/internal/http/middleware/bodylimit.go @@ -0,0 +1,19 @@ +package middleware + +import "net/http" + +// BodyLimit caps request body size. Returns 413 if exceeded. +func BodyLimit(maxBytes int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > maxBytes { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write([]byte(`{"error":"PayloadTooLarge","detail":"request body exceeds limit"}`)) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/middleware/cors.go b/internal/http/middleware/cors.go new file mode 100644 index 0000000..df16d94 --- /dev/null +++ b/internal/http/middleware/cors.go @@ -0,0 +1,18 @@ +package middleware + +import "net/http" + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + 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-Max-Age", "86400") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/http/middleware/logging.go b/internal/http/middleware/logging.go new file mode 100644 index 0000000..9655600 --- /dev/null +++ b/internal/http/middleware/logging.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "time" + + applog "github.com/noderunners/nip05api/internal/log" +) + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func newRequestID() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = newRequestID() + } + w.Header().Set("X-Request-ID", id) + ctx := applog.WithRequestID(r.Context(), id) + rec := &statusRecorder{ResponseWriter: w, status: 200} + start := time.Now() + next.ServeHTTP(rec, r.WithContext(ctx)) + slog.Info("http", + "request_id", id, + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + ) + }) +} diff --git a/internal/http/middleware/ratelimit.go b/internal/http/middleware/ratelimit.go new file mode 100644 index 0000000..8211b24 --- /dev/null +++ b/internal/http/middleware/ratelimit.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "github.com/go-chi/httprate" +) + +// RateLimit returns a middleware that limits requests per minute by IP. +// Admin routes are skipped. +func RateLimit(perMin int) func(http.Handler) http.Handler { + if perMin <= 0 { + return func(next http.Handler) http.Handler { return next } + } + limiter := httprate.LimitByIP(perMin, time.Minute) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/v1/admin/") { + next.ServeHTTP(w, r) + return + } + limiter(next).ServeHTTP(w, r) + }) + } +} diff --git a/internal/http/middleware/realip.go b/internal/http/middleware/realip.go new file mode 100644 index 0000000..8512f7a --- /dev/null +++ b/internal/http/middleware/realip.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "net" + "net/http" + "strings" +) + +// RealIP rewrites RemoteAddr from common reverse-proxy headers so downstream +// rate limiters and loggers see the original client IP. Trusted unconditionally; +// terminate this header at your proxy. +func RealIP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ip := clientIP(r); ip != "" { + r.RemoteAddr = ip + ":0" + } + next.ServeHTTP(w, r) + }) +} + +func clientIP(r *http.Request) string { + if ip := r.Header.Get("X-Real-IP"); ip != "" { + if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil { + return parsed.String() + } + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0]) + if parsed := net.ParseIP(first); parsed != nil { + return parsed.String() + } + } + return "" +} diff --git a/internal/http/middleware/recoverer.go b/internal/http/middleware/recoverer.go new file mode 100644 index 0000000..21d28b5 --- /dev/null +++ b/internal/http/middleware/recoverer.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "encoding/json" + "log/slog" + "net/http" + "runtime/debug" +) + +// Recoverer turns panics into 500 JSON responses without leaking the stack to +// clients. The full stack is logged at error level. +func Recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rv := recover(); rv != nil { + slog.Error("panic recovered", + "path", r.URL.Path, + "method", r.Method, + "err", rv, + "stack", string(debug.Stack()), + ) + if !headerWritten(w) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "InternalError", + "detail": "internal server error", + }) + } + } + }() + next.ServeHTTP(w, r) + }) +} + +// headerWritten is best-effort; if the response is hijacked we skip writing. +func headerWritten(w http.ResponseWriter) bool { + if rw, ok := w.(interface{ Status() int }); ok { + return rw.Status() != 0 + } + return false +} diff --git a/internal/http/server.go b/internal/http/server.go new file mode 100644 index 0000000..15243a4 --- /dev/null +++ b/internal/http/server.go @@ -0,0 +1,99 @@ +package http + +import ( + "context" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/config" + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/http/docs" + "github.com/noderunners/nip05api/internal/http/handlers" + "github.com/noderunners/nip05api/internal/http/middleware" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +type Deps struct { + Cfg *config.Config + DB *db.DB + Users *user.Service + Invoices *invoice.Service + DMs *dm.Service + Hooks *webhook.Service + Audit *audit.Logger + Version string +} + +func NewServer(d Deps) *http.Server { + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(middleware.Logging) + r.Use(middleware.CORS) + r.Use(middleware.BodyLimit(1 << 20)) // 1 MiB max request body + r.Use(middleware.RateLimit(d.Cfg.RateLimitPerMin)) + + health := &handlers.Health{DB: d.DB, Version: d.Version} + nostrJSON := &handlers.NostrJSON{Users: d.Users, Relays: d.Cfg.Nostr.Relays} + pricing := &handlers.Pricing{ + YearlySats: d.Cfg.Lightning.PriceYearlySats, + LifetimeSats: d.Cfg.Lightning.PriceLifetimeSats, + LightningEnabled: d.Cfg.Lightning.Enabled, + } + users := &handlers.Users{Users: d.Users, GraceDays: d.Cfg.Expiry.GraceDays} + usernames := &handlers.Usernames{Users: d.Users} + invoices := &handlers.Invoices{Service: d.Invoices, LightningEnabled: d.Cfg.Lightning.Enabled} + adminUsers := &handlers.AdminUsers{ + Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit, + Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL, + } + adminExtend := &handlers.AdminExtend{ + Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit, + Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL, + } + + r.Get("/healthz", health.Handle) + r.Get("/.well-known/nostr.json", nostrJSON.Handle) + r.Get("/openapi.json", docs.ServeJSON) + r.Get("/docs", docs.ServeUI) + r.Get("/docs/", docs.ServeUI) + + r.Route("/v1", func(r chi.Router) { + r.Get("/pricing", pricing.Handle) + r.Get("/users/{pubkey}", users.Get) + r.Get("/usernames/{name}/available", usernames.Available) + if d.Invoices != nil { + r.Post("/invoices", invoices.Create) + r.Get("/invoices/{payment_hash}", invoices.Get) + } + + r.Route("/admin", func(r chi.Router) { + r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey)) + r.Post("/users", adminUsers.Add) + r.Get("/users", adminUsers.List) + r.Put("/users/{pubkey}", adminUsers.Update) + r.Delete("/users/{pubkey}", adminUsers.Delete) + r.Post("/users/{pubkey}/extend", adminExtend.Handle) + }) + }) + + return &http.Server{ + Addr: d.Cfg.Addr(), + Handler: r, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } +} + +func Shutdown(ctx context.Context, srv *http.Server) error { + shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) +} diff --git a/internal/http/server_test.go b/internal/http/server_test.go new file mode 100644 index 0000000..d7c9619 --- /dev/null +++ b/internal/http/server_test.go @@ -0,0 +1,357 @@ +package http_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/config" + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/dm" + httpapi "github.com/noderunners/nip05api/internal/http" + "github.com/noderunners/nip05api/internal/messages" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" +const testKey = "test-admin-key-twenty-five-chars" + +type fixture struct { + srv *httptest.Server + db *db.DB +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + d, err := db.Open(dbPath) + if err != nil { + t.Fatal(err) + } + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Domain: "test.local", + Port: 0, + AdminAPIKey: testKey, + FrontendURL: "https://test.local/nip05", + Lightning: config.LightningConfig{Enabled: false}, + Expiry: config.ExpiryConfig{GraceDays: 30}, + ReservedUsernames: []string{"admin", "root"}, + RateLimitPerMin: 0, // disabled in tests + } + tmpls, _ := messages.Load("/nonexistent.yaml") + users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames) + dms := dm.NewService(dm.NewRepo(d), tmpls, false) + hooks := webhook.NewService(webhook.NewRepo(d), cfg.Domain, false) + + srv := httptest.NewServer(httpapi.NewServer(httpapi.Deps{ + Cfg: cfg, DB: d, Users: users, DMs: dms, Hooks: hooks, + Audit: audit.New(d), Version: "test", + }).Handler) + t.Cleanup(func() { + srv.Close() + _ = d.Close() + }) + return &fixture{srv: srv, db: d} +} + +func (f *fixture) get(t *testing.T, path string) (*http.Response, []byte) { + t.Helper() + resp, err := http.Get(f.srv.URL + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body := readAll(t, resp) + return resp, body +} + +func (f *fixture) admin(t *testing.T, method, path string, payload any) (*http.Response, []byte) { + t.Helper() + var body []byte + if payload != nil { + var err error + body, err = json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + } + req, _ := http.NewRequest(method, f.srv.URL+path, bytes.NewReader(body)) + req.Header.Set("X-API-Key", testKey) + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + return resp, readAll(t, resp) +} + +func readAll(t *testing.T, resp *http.Response) []byte { + t.Helper() + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(resp.Body); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestHealthz(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/healthz") + if resp.StatusCode != 200 { + t.Fatalf("status %d: %s", resp.StatusCode, body) + } + var got map[string]string + _ = json.Unmarshal(body, &got) + if got["status"] != "ok" || got["version"] != "test" { + t.Errorf("body: %s", body) + } +} + +func TestPricing(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/v1/pricing") + if resp.StatusCode != 200 { + t.Fatalf("status %d: %s", resp.StatusCode, body) + } + var got map[string]any + _ = json.Unmarshal(body, &got) + if _, ok := got["yearly_sats"]; !ok { + t.Errorf("missing yearly_sats: %s", body) + } +} + +func TestNostrJSON_EmptyAndPopulated(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/.well-known/nostr.json") + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + var got map[string]any + _ = json.Unmarshal(body, &got) + if got["names"] == nil { + t.Errorf("missing names key: %s", body) + } + + f.admin(t, "POST", "/v1/admin/users", map[string]any{ + "pubkey": testHex, "username": "alice", + "subscription_type": "yearly", "years": 1, + }) + + _, body = f.get(t, "/.well-known/nostr.json") + var populated struct { + Names map[string]string `json:"names"` + } + if err := json.Unmarshal(body, &populated); err != nil { + t.Fatal(err) + } + if populated.Names["alice"] != testHex { + t.Errorf("alice not in nostr.json: %s", body) + } +} + +func TestUsernameAvailability(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/v1/usernames/alice/available") + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + var got map[string]any + _ = json.Unmarshal(body, &got) + if got["available"] != true { + t.Errorf("expected available=true: %s", body) + } + + // Reserved name. + _, body = f.get(t, "/v1/usernames/admin/available") + _ = json.Unmarshal(body, &got) + if got["available"] != false { + t.Errorf("admin should be reserved: %s", body) + } + + // Invalid format. + _, body = f.get(t, "/v1/usernames/-bad/available") + _ = json.Unmarshal(body, &got) + if got["available"] != false { + t.Errorf("-bad should be invalid: %s", body) + } +} + +func TestAdminAuthGate(t *testing.T) { + f := newFixture(t) + resp, _ := f.get(t, "/v1/admin/users") + if resp.StatusCode != 401 { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + resp, _ = f.admin(t, "GET", "/v1/admin/users", nil) + if resp.StatusCode != 200 { + t.Fatalf("expected 200 with key, got %d", resp.StatusCode) + } +} + +func TestAdminLifecycle(t *testing.T) { + f := newFixture(t) + + // Add. + resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{ + "pubkey": testHex, "username": "alice", + "subscription_type": "yearly", "years": 1, + }) + if resp.StatusCode != 201 { + t.Fatalf("add status %d: %s", resp.StatusCode, body) + } + + // Lookup hex. + resp, body = f.get(t, "/v1/users/"+testHex) + if resp.StatusCode != 200 { + t.Fatalf("lookup status %d", resp.StatusCode) + } + var got map[string]any + _ = json.Unmarshal(body, &got) + if got["is_whitelisted"] != true || got["username"] != "alice" { + t.Errorf("unexpected lookup body: %s", body) + } + + // Lookup npub form. + npub := "npub1p6xyr67d2k5d3kewp2xr5juutehh4zuup50z7wjtt3kharu6pvwqjh7065" + resp, _ = f.get(t, "/v1/users/"+npub) + if resp.StatusCode != 200 { + t.Fatalf("npub lookup status %d", resp.StatusCode) + } + + // Username unavailable now. + _, body = f.get(t, "/v1/usernames/alice/available") + _ = json.Unmarshal(body, &got) + if got["available"] != false { + t.Errorf("expected unavailable: %s", body) + } + + // Extend. + resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/extend", + map[string]any{"years": 2}) + if resp.StatusCode != 200 { + t.Fatalf("extend status %d: %s", resp.StatusCode, body) + } + + // Update username. + resp, body = f.admin(t, "PUT", "/v1/admin/users/"+testHex, + map[string]any{"username": "alice2"}) + if resp.StatusCode != 200 { + t.Fatalf("update status %d: %s", resp.StatusCode, body) + } + + // Delete. + resp, _ = f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil) + if resp.StatusCode != 200 { + t.Fatalf("delete status %d", resp.StatusCode) + } + + // Gone. + resp, _ = f.get(t, "/v1/users/"+testHex) + if resp.StatusCode != 404 { + t.Fatalf("expected 404 after delete, got %d", resp.StatusCode) + } + + // Audit log should reflect every admin action. + wantActions := []string{"user.added", "user.extended", "user.username_changed", "user.deleted"} + rows, qerr := f.db.Query(`SELECT action FROM audit_log`) + if qerr != nil { + t.Fatal(qerr) + } + defer rows.Close() + seen := map[string]bool{} + for rows.Next() { + var action string + if err := rows.Scan(&action); err != nil { + t.Fatal(err) + } + seen[action] = true + } + for _, action := range wantActions { + if !seen[action] { + t.Errorf("missing audit action %q (got %v)", action, seen) + } + } +} + +func TestAdminAdd_BadInputs(t *testing.T) { + f := newFixture(t) + + resp, _ := f.admin(t, "POST", "/v1/admin/users", map[string]any{ + "pubkey": "notapubkey", "username": "alice", + "subscription_type": "yearly", + }) + if resp.StatusCode != 400 { + t.Errorf("bad pubkey: expected 400, got %d", resp.StatusCode) + } + + resp, _ = f.admin(t, "POST", "/v1/admin/users", map[string]any{ + "pubkey": testHex, "username": "admin", + "subscription_type": "yearly", + }) + if resp.StatusCode == 200 || resp.StatusCode == 201 { + t.Errorf("reserved username accepted: %d", resp.StatusCode) + } +} + +func TestOpenAPI(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/openapi.json") + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + var spec map[string]any + if err := json.Unmarshal(body, &spec); err != nil { + t.Fatalf("openapi not valid json: %v", err) + } + if spec["openapi"] == nil { + t.Errorf("missing openapi field: %s", body[:min(200, len(body))]) + } +} + +func TestDocsPage(t *testing.T) { + f := newFixture(t) + resp, body := f.get(t, "/docs") + if resp.StatusCode != 200 { + t.Fatalf("status %d", resp.StatusCode) + } + if !bytes.Contains(body, []byte("swagger-ui")) { + t.Errorf("expected swagger UI markup") + } +} + +func TestBodyLimit(t *testing.T) { + f := newFixture(t) + huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB + body := []byte(`{"pubkey":"` + testHex + `","username":"alice","subscription_type":"yearly","data":"` + string(huge) + `"}`) + req, _ := http.NewRequest("POST", f.srv.URL+"/v1/admin/users", bytes.NewReader(body)) + req.Header.Set("X-API-Key", testKey) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 413 { + t.Errorf("expected 413, got %d", resp.StatusCode) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/invoice/lnbits.go b/internal/invoice/lnbits.go new file mode 100644 index 0000000..6a95ed3 --- /dev/null +++ b/internal/invoice/lnbits.go @@ -0,0 +1,104 @@ +package invoice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type LNbitsClient struct { + baseURL string + invoiceKey string + hc *http.Client +} + +func NewLNbits(baseURL, invoiceKey string) *LNbitsClient { + return &LNbitsClient{ + baseURL: strings.TrimRight(baseURL, "/"), + invoiceKey: invoiceKey, + hc: &http.Client{Timeout: 15 * time.Second}, + } +} + +type createReq struct { + Out bool `json:"out"` + Amount int64 `json:"amount"` + Memo string `json:"memo"` + Expiry int `json:"expiry,omitempty"` +} + +type createResp struct { + PaymentHash string `json:"payment_hash"` + PaymentRequest string `json:"payment_request"` + BOLT11 string `json:"bolt11"` +} + +type statusResp struct { + Paid bool `json:"paid"` + Pending bool `json:"pending"` + Details *struct { + Pending bool `json:"pending"` + Status string `json:"status"` + } `json:"details"` +} + +func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int) (string, string, error) { + body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs}) + if err != nil { + return "", "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/payments", bytes.NewReader(body)) + if err != nil { + return "", "", err + } + req.Header.Set("X-Api-Key", c.invoiceKey) + req.Header.Set("Content-Type", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return "", "", fmt.Errorf("%w: %v", ErrLNbits, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode/100 != 2 { + return "", "", fmt.Errorf("%w: %s", ErrLNbits, string(b)) + } + var cr createResp + if err := json.Unmarshal(b, &cr); err != nil { + return "", "", fmt.Errorf("%w: decode: %v", ErrLNbits, err) + } + pr := cr.PaymentRequest + if pr == "" { + pr = cr.BOLT11 + } + return cr.PaymentHash, pr, nil +} + +func (c *LNbitsClient) Status(ctx context.Context, hash string) (bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/payments/"+hash, nil) + if err != nil { + return false, err + } + req.Header.Set("X-Api-Key", c.invoiceKey) + resp, err := c.hc.Do(req) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrLNbits, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(resp.Body) + return false, fmt.Errorf("%w: %s", ErrLNbits, string(b)) + } + var sr statusResp + if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil { + return false, err + } + return sr.Paid, nil +} diff --git a/internal/invoice/model.go b/internal/invoice/model.go new file mode 100644 index 0000000..8cccbc3 --- /dev/null +++ b/internal/invoice/model.go @@ -0,0 +1,52 @@ +package invoice + +import ( + "errors" + "time" + + "github.com/noderunners/nip05api/internal/user" +) + +type PendingInvoice struct { + PaymentHash string + PaymentRequest string + Username string + Pubkey string + SubscriptionType user.SubscriptionType + Years int + AmountSats int64 + ExpiresAt time.Time + Paid bool + IsRenewal bool + CreatedAt time.Time + // TargetExpiresAt captures the user's new expiry value at first + // confirmation. Persisted so retries after a crash apply the same + // absolute value and stay idempotent. nil for lifetime. + TargetExpiresAt *time.Time + // TargetSet is true when target_expires_at has been written (even if + // the value is NULL for lifetime). Distinguishes "unset" from "lifetime". + TargetSet bool +} + +type Status string + +const ( + StatusPending Status = "pending" + StatusPaid Status = "paid" + StatusExpired Status = "expired" +) + +var ( + ErrInvoiceNotFound = errors.New("invoice not found") + ErrLNbits = errors.New("lnbits error") +) + +func (p *PendingInvoice) Status() Status { + if p.Paid { + return StatusPaid + } + if time.Now().UTC().After(p.ExpiresAt) { + return StatusExpired + } + return StatusPending +} diff --git a/internal/invoice/repo.go b/internal/invoice/repo.go new file mode 100644 index 0000000..bd1f6cb --- /dev/null +++ b/internal/invoice/repo.go @@ -0,0 +1,163 @@ +package invoice + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/user" +) + +type Repo struct{ db *db.DB } + +func NewRepo(d *db.DB) *Repo { return &Repo{db: d} } + +const invCols = `payment_hash, payment_request, username, pubkey, subscription_type, + years, amount_sats, expires_at, paid, is_renewal, created_at, target_expires_at` + +func scanInvoice(row interface{ Scan(...any) error }) (*PendingInvoice, error) { + var p PendingInvoice + var sub, expires, created string + var paid, renewal int + var target sql.NullString + if err := row.Scan(&p.PaymentHash, &p.PaymentRequest, &p.Username, &p.Pubkey, + &sub, &p.Years, &p.AmountSats, &expires, &paid, &renewal, &created, &target); err != nil { + return nil, err + } + p.SubscriptionType = user.SubscriptionType(sub) + if t, err := time.Parse(time.RFC3339, expires); err == nil { + p.ExpiresAt = t + } + if t, err := time.Parse(time.RFC3339, created); err == nil { + p.CreatedAt = t + } else if t, err := time.Parse("2006-01-02 15:04:05", created); err == nil { + p.CreatedAt = t + } + p.Paid = paid == 1 + p.IsRenewal = renewal == 1 + if target.Valid { + p.TargetSet = true + if target.String != "" { + if t, err := time.Parse(time.RFC3339, target.String); err == nil { + p.TargetExpiresAt = &t + } + } + } + return &p, nil +} + +func (r *Repo) Insert(ctx context.Context, p *PendingInvoice) error { + var target any + if p.TargetSet { + if p.TargetExpiresAt != nil { + target = p.TargetExpiresAt.UTC().Format(time.RFC3339) + } else { + target = "" + } + } + _, err := r.db.ExecContext(ctx, `INSERT INTO pending_invoices + (payment_hash, payment_request, username, pubkey, subscription_type, + years, amount_sats, expires_at, paid, is_renewal, target_expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.PaymentHash, p.PaymentRequest, p.Username, p.Pubkey, + string(p.SubscriptionType), p.Years, p.AmountSats, + p.ExpiresAt.UTC().Format(time.RFC3339), + boolToInt(p.Paid), boolToInt(p.IsRenewal), target) + return err +} + +func (r *Repo) Get(ctx context.Context, hash string) (*PendingInvoice, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+invCols+` FROM pending_invoices WHERE payment_hash = ?`, hash) + p, err := scanInvoice(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInvoiceNotFound + } + return p, err +} + +func (r *Repo) MarkPaid(ctx context.Context, hash string) error { + _, err := r.db.ExecContext(ctx, `UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ?`, hash) + return err +} + +// SetTargetIfUnset writes target_expires_at only when currently NULL. +// Returns true if this call won the race. Lifetime is encoded as empty string, +// allowing the caller to distinguish "not yet set" (NULL) from "set to nil". +func (r *Repo) SetTargetIfUnset(ctx context.Context, hash string, target *time.Time) (bool, error) { + stored := "" + if target != nil { + stored = target.UTC().Format(time.RFC3339) + } + res, err := r.db.ExecContext(ctx, + `UPDATE pending_invoices SET target_expires_at = ? WHERE payment_hash = ? AND target_expires_at IS NULL`, + stored, hash) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +// ClaimPaid atomically transitions paid 0 → 1. Returns true if the caller +// performed the transition (i.e. it was unpaid before this call). +func (r *Repo) ClaimPaid(ctx context.Context, hash string) (bool, error) { + res, err := r.db.ExecContext(ctx, + `UPDATE pending_invoices SET paid = 1 WHERE payment_hash = ? AND paid = 0`, hash) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +func (r *Repo) ListUnpaid(ctx context.Context) ([]*PendingInvoice, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+invCols+` FROM pending_invoices + WHERE paid = 0 AND expires_at > ?`, + time.Now().UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + defer rows.Close() + out := []*PendingInvoice{} + for rows.Next() { + p, err := scanInvoice(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// HasUnpaidForUsername returns true if there is an active unpaid invoice for the username. +func (r *Repo) HasUnpaidForUsername(ctx context.Context, username string) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices + WHERE username = ? COLLATE NOCASE AND paid = 0 AND expires_at > ?`, + username, time.Now().UTC().Format(time.RFC3339)).Scan(&count) + return count > 0, err +} + +// HasUnpaidForPubkey returns true if there is an active unpaid invoice for the pubkey. +func (r *Repo) HasUnpaidForPubkey(ctx context.Context, pubkey string) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM pending_invoices + WHERE pubkey = ? AND paid = 0 AND expires_at > ?`, + pubkey, time.Now().UTC().Format(time.RFC3339)).Scan(&count) + return count > 0, err +} + +func (r *Repo) PurgeOldUnpaid(ctx context.Context) error { + cutoff := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339) + _, err := r.db.ExecContext(ctx, `DELETE FROM pending_invoices WHERE paid = 0 AND expires_at < ?`, cutoff) + return err +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/invoice/service.go b/internal/invoice/service.go new file mode 100644 index 0000000..e868ebd --- /dev/null +++ b/internal/invoice/service.go @@ -0,0 +1,186 @@ +package invoice + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/noderunners/nip05api/internal/user" +) + +type CreateRequest struct { + Username string + Pubkey string + SubscriptionType user.SubscriptionType + Years int +} + +type Pricing struct { + YearlySats int64 + LifetimeSats int64 + ExpiryMins int +} + +type Service struct { + repo *Repo + users *user.Service + lnbits *LNbitsClient + pricing Pricing + domain string +} + +func NewService(repo *Repo, users *user.Service, ln *LNbitsClient, p Pricing, domain string) *Service { + return &Service{repo: repo, users: users, lnbits: ln, pricing: p, domain: domain} +} + +func (s *Service) Repo() *Repo { return s.repo } + +func (s *Service) Pricing() Pricing { return s.pricing } + +var ( + ErrUsernameMismatch = errors.New("username does not match existing record") + ErrUsernameTaken = errors.New("username taken") + ErrInvalidYears = errors.New("invalid years") + ErrLifetimeAccess = errors.New("user already has lifetime access") + ErrPendingInvoiceExists = errors.New("pending unpaid invoice already exists") +) + +// Create computes amount, calls LNbits, persists pending invoice. Detects renewal. +func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoice, error) { + if !req.SubscriptionType.Valid() { + return nil, fmt.Errorf("invalid subscription_type") + } + if req.SubscriptionType == user.SubYearly { + if req.Years <= 0 { + req.Years = 1 + } + if req.Years > 10 { + return nil, ErrInvalidYears + } + } else { + req.Years = 0 + } + + hasPendingPubkey, err := s.repo.HasUnpaidForPubkey(ctx, req.Pubkey) + if err != nil { + return nil, err + } + if hasPendingPubkey { + return nil, ErrPendingInvoiceExists + } + + username := user.NormalizeUsername(req.Username) + + existing, err := s.users.Repo().GetByPubkey(ctx, req.Pubkey) + isRenewal := false + switch { + case err == nil: + if existing.IsLifetime() && existing.IsActive { + return nil, ErrLifetimeAccess + } + isRenewal = true + if username == "" { + username = existing.Username + } else if username != existing.Username { + return nil, ErrUsernameMismatch + } + case errors.Is(err, user.ErrUserNotFound): + if username == "" { + generated, gerr := s.allocateProvisionalUsername(ctx, req.Pubkey) + if gerr != nil { + return nil, gerr + } + username = generated + } else { + if err := user.ValidateUsername(username, s.users.Reserved()); err != nil { + return nil, err + } + taken, err := s.users.Repo().GetByUsername(ctx, username) + if err == nil && taken.Pubkey != req.Pubkey { + return nil, ErrUsernameTaken + } + hasPending, err := s.repo.HasUnpaidForUsername(ctx, username) + if err != nil { + return nil, err + } + if hasPending { + return nil, ErrUsernameTaken + } + } + default: + return nil, err + } + + amount := s.pricing.YearlySats * int64(req.Years) + if req.SubscriptionType == user.SubLifetime { + amount = s.pricing.LifetimeSats + } + + memo := fmt.Sprintf("%s@%s", username, s.domain) + if isRenewal { + memo = "renewal: " + memo + } + + expirySecs := s.pricing.ExpiryMins * 60 + hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs) + if err != nil { + return nil, err + } + + now := time.Now().UTC() + p := &PendingInvoice{ + PaymentHash: hash, + PaymentRequest: request, + Username: username, + Pubkey: req.Pubkey, + SubscriptionType: req.SubscriptionType, + Years: req.Years, + AmountSats: amount, + ExpiresAt: now.Add(time.Duration(s.pricing.ExpiryMins) * time.Minute), + Paid: false, + IsRenewal: isRenewal, + CreatedAt: now, + } + if req.SubscriptionType == user.SubYearly { + var current *time.Time + if existing != nil { + current = existing.ExpiresAt + } + p.TargetExpiresAt = user.YearlyTargetExpiry(current, req.Years, now) + p.TargetSet = true + } + if err := s.repo.Insert(ctx, p); err != nil { + return nil, err + } + return p, nil +} + +// allocateProvisionalUsername finds a unique placeholder handle for a pubkey +// that has no chosen username yet. The base form derives from the pubkey, with +// numeric suffixes added on the rare collision. +func (s *Service) allocateProvisionalUsername(ctx context.Context, pubkey string) (string, error) { + base := user.ProvisionalUsername(pubkey) + for attempt := 0; attempt < 20; attempt++ { + candidate := base + if attempt > 0 { + candidate = fmt.Sprintf("%s_%d", base, attempt+1) + } + if err := user.ValidateUsername(candidate, s.users.Reserved()); err != nil { + continue + } + taken, err := s.users.Repo().GetByUsername(ctx, candidate) + if err == nil && taken.Pubkey != pubkey { + continue + } + hasPending, err := s.repo.HasUnpaidForUsername(ctx, candidate) + if err != nil { + return "", err + } + if hasPending { + continue + } + return candidate, nil + } + return "", fmt.Errorf("could not allocate provisional username") +} diff --git a/internal/invoice/service_test.go b/internal/invoice/service_test.go new file mode 100644 index 0000000..02b3d33 --- /dev/null +++ b/internal/invoice/service_test.go @@ -0,0 +1,227 @@ +package invoice_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/user" +) + +const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + +func newServiceFixture(t *testing.T) (*invoice.Service, *user.Service, *httptest.Server) { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + + ln := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "payment_hash": "hash-" + r.URL.Path, + "payment_request": "lnbcfake", + }) + })) + t.Cleanup(ln.Close) + + users := user.NewService(user.NewRepo(d), []string{"admin", "root"}) + client := invoice.NewLNbits(ln.URL, "key") + pricing := invoice.Pricing{YearlySats: 1000, LifetimeSats: 5000, ExpiryMins: 30} + svc := invoice.NewService(invoice.NewRepo(d), users, client, pricing, "test.local") + return svc, users, ln +} + +func TestCreate_PubkeyOnlyDefaultsLifetime(t *testing.T) { + svc, _, _ := newServiceFixture(t) + + p, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + SubscriptionType: user.SubLifetime, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if p.SubscriptionType != user.SubLifetime { + t.Errorf("expected lifetime, got %q", p.SubscriptionType) + } + if p.AmountSats != 5000 { + t.Errorf("expected lifetime price, got %d", p.AmountSats) + } + if !strings.HasPrefix(p.Username, "u_") { + t.Errorf("expected provisional username, got %q", p.Username) + } + if err := user.ValidateUsername(p.Username, nil); err != nil { + t.Errorf("provisional name should be valid: %v", err) + } +} + +func TestCreate_YearlyDefaultsOneYear(t *testing.T) { + svc, _, _ := newServiceFixture(t) + + p, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + Username: "alice", + SubscriptionType: user.SubYearly, + Years: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if p.Years != 1 || p.AmountSats != 1000 { + t.Errorf("unexpected pending: years=%d amount=%d", p.Years, p.AmountSats) + } + if p.Username != "alice" { + t.Errorf("expected explicit username, got %q", p.Username) + } +} + +func TestCreate_RenewalReusesUsername(t *testing.T) { + svc, users, _ := newServiceFixture(t) + + if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { + t.Fatal(err) + } + + p, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + SubscriptionType: user.SubLifetime, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if !p.IsRenewal { + t.Error("expected renewal flag") + } + if p.Username != "alice" { + t.Errorf("expected stored username, got %q", p.Username) + } +} + +func TestCreate_BlocksActiveLifetimeUser(t *testing.T) { + svc, users, _ := newServiceFixture(t) + + if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubLifetime, 0, false); err != nil { + t.Fatal(err) + } + + _, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + SubscriptionType: user.SubYearly, + Years: 1, + }) + if !errors.Is(err, invoice.ErrLifetimeAccess) { + t.Fatalf("expected ErrLifetimeAccess, got %v", err) + } +} + +func TestCreate_RejectsDuplicatePending(t *testing.T) { + svc, _, _ := newServiceFixture(t) + + if _, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + Username: "alice", + SubscriptionType: user.SubYearly, + Years: 1, + }); err != nil { + t.Fatalf("first create: %v", err) + } + + _, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + Username: "alice", + SubscriptionType: user.SubYearly, + Years: 1, + }) + if !errors.Is(err, invoice.ErrPendingInvoiceExists) { + t.Fatalf("expected ErrPendingInvoiceExists, got %v", err) + } +} + +func TestCreate_YearlyPersistsTargetExpiry(t *testing.T) { + svc, users, _ := newServiceFixture(t) + + if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { + t.Fatal(err) + } + existing, err := users.Repo().GetByPubkey(context.Background(), testHex) + if err != nil { + t.Fatal(err) + } + + before := time.Now().UTC() + p, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + SubscriptionType: user.SubYearly, + Years: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + stored, err := svc.Repo().Get(context.Background(), p.PaymentHash) + if err != nil { + t.Fatal(err) + } + if !stored.TargetSet || stored.TargetExpiresAt == nil { + t.Fatalf("expected target persisted, got set=%v at=%v", stored.TargetSet, stored.TargetExpiresAt) + } + want := existing.ExpiresAt.AddDate(1, 0, 0) + if !stored.TargetExpiresAt.Equal(want) { + t.Errorf("target stacking mismatch: got %v want %v", stored.TargetExpiresAt, want) + } + if stored.TargetExpiresAt.Before(before) { + t.Error("target should be in the future") + } +} + +func TestCreate_YearlyExpiredUserStartsFromNow(t *testing.T) { + svc, users, _ := newServiceFixture(t) + + if _, err := users.CreateOrActivate(context.Background(), testHex, "alice", user.SubYearly, 1, false); err != nil { + t.Fatal(err) + } + u, err := users.Repo().GetByPubkey(context.Background(), testHex) + if err != nil { + t.Fatal(err) + } + past := time.Now().UTC().AddDate(0, 0, -1) + u.ExpiresAt = &past + if err := users.Repo().Update(context.Background(), u); err != nil { + t.Fatal(err) + } + + before := time.Now().UTC() + p, err := svc.Create(context.Background(), invoice.CreateRequest{ + Pubkey: testHex, + SubscriptionType: user.SubYearly, + Years: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + stored, err := svc.Repo().Get(context.Background(), p.PaymentHash) + if err != nil { + t.Fatal(err) + } + if stored.TargetExpiresAt == nil { + t.Fatal("expected target") + } + want := before.AddDate(1, 0, 0) + delta := stored.TargetExpiresAt.Sub(want) + if delta < -2*time.Second || delta > 2*time.Second { + t.Errorf("expected ~now+1y (%v), got %v", want, stored.TargetExpiresAt) + } +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..1145b03 --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,48 @@ +package log + +import ( + "context" + "log/slog" + "os" + "strings" +) + +type ctxKey string + +const requestIDKey ctxKey = "request_id" + +func Setup(level string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + logger := slog.New(h) + slog.SetDefault(logger) + return logger +} + +func WithRequestID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, requestIDKey, id) +} + +func RequestID(ctx context.Context) string { + if v, ok := ctx.Value(requestIDKey).(string); ok { + return v + } + return "" +} + +func From(ctx context.Context) *slog.Logger { + if id := RequestID(ctx); id != "" { + return slog.Default().With("request_id", id) + } + return slog.Default() +} diff --git a/internal/messages/defaults.go b/internal/messages/defaults.go new file mode 100644 index 0000000..f97d152 --- /dev/null +++ b/internal/messages/defaults.go @@ -0,0 +1,36 @@ +package messages + +const defaultWelcome = `welcome to nip-05 on {domain} + +you're now {username}@{domain} +expires: {expires_at} + +manage your identity: {frontend_url}?pubkey={npub} +` + +const defaultExpiringSoon = `heads up — your nip-05 {username}@{domain} expires in {days_remaining} days ({expires_at}) + +renew here: {frontend_url}?pubkey={npub} + +yearly or lifetime, your call. +` + +const defaultExpired = `your nip-05 {username}@{domain} has expired and been removed. + +want it back? same username is reserved for you for {grace_days} days: +{frontend_url}?pubkey={npub} +` + +const defaultExtended = `{username}@{domain} renewed + +new expiry: {expires_at} +` + +func defaultTemplates() map[string]string { + return map[string]string{ + "welcome": defaultWelcome, + "expiring_soon": defaultExpiringSoon, + "expired": defaultExpired, + "extended": defaultExtended, + } +} diff --git a/internal/messages/messages.go b/internal/messages/messages.go new file mode 100644 index 0000000..7635f7a --- /dev/null +++ b/internal/messages/messages.go @@ -0,0 +1,52 @@ +package messages + +import ( + "errors" + "fmt" + "log/slog" + "os" + + "gopkg.in/yaml.v3" +) + +var ErrUnknownEvent = errors.New("unknown event type") + +type Templates struct { + templates map[string]string +} + +func Load(path string) (*Templates, error) { + t := &Templates{templates: defaultTemplates()} + + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + slog.Warn("messages file not found, using embedded defaults", "path", path) + return t, nil + } + return nil, fmt.Errorf("read messages file: %w", err) + } + + parsed := map[string]string{} + if err := yaml.Unmarshal(b, &parsed); err != nil { + return nil, fmt.Errorf("parse messages file: %w", err) + } + + for k, v := range parsed { + t.templates[k] = v + } + return t, nil +} + +func (t *Templates) Get(eventType string) (string, error) { + tmpl, ok := t.templates[eventType] + if !ok { + return "", fmt.Errorf("%w: %s", ErrUnknownEvent, eventType) + } + return tmpl, nil +} + +func (t *Templates) Has(eventType string) bool { + _, ok := t.templates[eventType] + return ok +} diff --git a/internal/messages/render.go b/internal/messages/render.go new file mode 100644 index 0000000..c473202 --- /dev/null +++ b/internal/messages/render.go @@ -0,0 +1,18 @@ +package messages + +import "strings" + +func (t *Templates) Render(eventType string, vars map[string]string) (string, error) { + tmpl, err := t.Get(eventType) + if err != nil { + return "", err + } + if tmpl == "" { + return "", nil + } + out := tmpl + for k, v := range vars { + out = strings.ReplaceAll(out, "{"+k+"}", v) + } + return strings.TrimSpace(out), nil +} diff --git a/internal/messages/render_test.go b/internal/messages/render_test.go new file mode 100644 index 0000000..7bf44e7 --- /dev/null +++ b/internal/messages/render_test.go @@ -0,0 +1,62 @@ +package messages + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRender_Defaults(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + tpl, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatalf("load defaults: %v", err) + } + out, err := tpl.Render("welcome", map[string]string{ + "username": "alice", + "domain": "azzamo.net", + "expires_at": "2027-01-01", + "npub": "npub1xxx", + "frontend_url": "https://azzamo.net/nip05", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + if !strings.Contains(out, "alice@azzamo.net") { + t.Errorf("rendered: %q", out) + } +} + +func TestRender_EmptyDisables(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "messages.yaml") + if err := os.WriteFile(path, []byte("welcome: \"\"\nextended: hi {username}\n"), 0o644); err != nil { + t.Fatal(err) + } + tpl, err := Load(path) + if err != nil { + t.Fatal(err) + } + out, err := tpl.Render("welcome", map[string]string{"username": "bob"}) + if err != nil { + t.Fatalf("render welcome: %v", err) + } + if out != "" { + t.Errorf("empty template should render empty, got %q", out) + } + out2, _ := tpl.Render("extended", map[string]string{"username": "bob"}) + if out2 != "hi bob" { + t.Errorf("extended got %q", out2) + } +} + +func TestRender_UnknownEvent(t *testing.T) { + tpl, err := Load(filepath.Join(t.TempDir(), "missing.yaml")) + if err != nil { + t.Fatal(err) + } + if _, err := tpl.Render("does_not_exist", nil); err == nil { + t.Error("expected error for unknown event") + } +} diff --git a/internal/nostr/keys.go b/internal/nostr/keys.go new file mode 100644 index 0000000..36e76da --- /dev/null +++ b/internal/nostr/keys.go @@ -0,0 +1,72 @@ +package nostr + +import ( + "encoding/hex" + "errors" + "strings" + + gn "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" +) + +var ( + ErrInvalidPubkey = errors.New("invalid pubkey") + ErrInvalidNsec = errors.New("invalid nsec") +) + +// NormalizePubkey accepts npub bech32 or 64-char hex and returns lowercase hex. +func NormalizePubkey(in string) (string, error) { + in = strings.TrimSpace(in) + if in == "" { + return "", ErrInvalidPubkey + } + if strings.HasPrefix(in, "npub1") { + prefix, data, err := nip19.Decode(in) + if err != nil { + return "", ErrInvalidPubkey + } + if prefix != "npub" { + return "", ErrInvalidPubkey + } + s, ok := data.(string) + if !ok { + return "", ErrInvalidPubkey + } + return strings.ToLower(s), nil + } + if len(in) != 64 { + return "", ErrInvalidPubkey + } + if _, err := hex.DecodeString(in); err != nil { + return "", ErrInvalidPubkey + } + if !gn.IsValidPublicKey(in) { + return "", ErrInvalidPubkey + } + return strings.ToLower(in), nil +} + +// HexToNpub converts hex pubkey to npub bech32. Empty string on error. +func HexToNpub(hexpk string) string { + npub, err := nip19.EncodePublicKey(hexpk) + if err != nil { + return "" + } + return npub +} + +// NsecToHex decodes nsec1... to hex private key. +func NsecToHex(nsec string) (string, error) { + prefix, data, err := nip19.Decode(strings.TrimSpace(nsec)) + if err != nil { + return "", ErrInvalidNsec + } + if prefix != "nsec" { + return "", ErrInvalidNsec + } + s, ok := data.(string) + if !ok { + return "", ErrInvalidNsec + } + return strings.ToLower(s), nil +} diff --git a/internal/nostr/keys_test.go b/internal/nostr/keys_test.go new file mode 100644 index 0000000..dfd70fc --- /dev/null +++ b/internal/nostr/keys_test.go @@ -0,0 +1,41 @@ +package nostr + +import ( + "strings" + "testing" +) + +func TestNormalizePubkey_Hex(t *testing.T) { + hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + got, err := NormalizePubkey(hex) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != strings.ToLower(hex) { + t.Fatalf("got %q want %q", got, hex) + } +} + +func TestNormalizePubkey_BadInput(t *testing.T) { + cases := []string{"", "abc", "not-an-npub", "npub1invalid", strings.Repeat("z", 64)} + for _, c := range cases { + if _, err := NormalizePubkey(c); err == nil { + t.Errorf("expected error for %q", c) + } + } +} + +func TestNormalizePubkey_NpubRoundtrip(t *testing.T) { + hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + npub := HexToNpub(hex) + if npub == "" || !strings.HasPrefix(npub, "npub1") { + t.Fatalf("HexToNpub failed: %q", npub) + } + got, err := NormalizePubkey(npub) + if err != nil { + t.Fatalf("decode npub: %v", err) + } + if got != hex { + t.Fatalf("roundtrip got %q want %q", got, hex) + } +} diff --git a/internal/nostr/profile.go b/internal/nostr/profile.go new file mode 100644 index 0000000..2a014fa --- /dev/null +++ b/internal/nostr/profile.go @@ -0,0 +1,81 @@ +package nostr + +import ( + "context" + "encoding/json" + "time" + + gn "github.com/nbd-wtf/go-nostr" +) + +// Metadata mirrors the kind:0 profile JSON. We accept both snake_case +// (`display_name`, NIP-24) and the deprecated camelCase `displayName` since +// some clients still publish only the latter. `Username` is also deprecated +// but appears in older profiles; per NIP-24 it should be ignored in favor of +// `Name`, but we surface it as a last-resort fallback. +type Metadata struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + DisplayNameAlt string `json:"displayName"` + Username string `json:"username"` + NIP05 string `json:"nip05"` + About string `json:"about"` + Picture string `json:"picture"` +} + +// ParseMetadata decodes a kind:0 content payload. +func ParseMetadata(content string) (*Metadata, error) { + var md Metadata + if err := json.Unmarshal([]byte(content), &md); err != nil { + return nil, err + } + return &md, nil +} + +// FetchMetadata pulls the most recent kind:0 event for a hex pubkey across the pool. +// Each relay may return multiple replacement events; we keep the one with the +// highest CreatedAt across every relay reached before timeout. +func FetchMetadata(ctx context.Context, p *Pool, hexpk string) (*Metadata, error) { + filter := gn.Filter{ + Kinds: []int{0}, + Authors: []string{hexpk}, + Limit: 100, + } + + var newest *gn.Event + for _, url := range p.URLs() { + r, err := p.Connect(ctx, url) + if err != nil { + continue + } + subCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + sub, err := r.Subscribe(subCtx, gn.Filters{filter}) + if err != nil { + cancel() + continue + } + loop: + for { + select { + case ev, ok := <-sub.Events: + if !ok { + break loop + } + if newest == nil || ev.CreatedAt > newest.CreatedAt { + newest = ev + } + case <-sub.EndOfStoredEvents: + break loop + case <-subCtx.Done(): + break loop + } + } + sub.Unsub() + cancel() + } + + if newest == nil { + return nil, nil + } + return ParseMetadata(newest.Content) +} diff --git a/internal/nostr/profile_test.go b/internal/nostr/profile_test.go new file mode 100644 index 0000000..045516b --- /dev/null +++ b/internal/nostr/profile_test.go @@ -0,0 +1,59 @@ +package nostr + +import "testing" + +func TestParseMetadata(t *testing.T) { + cases := []struct { + desc, in string + name, dn, dnAlt string + username, nip05 string + }{ + { + desc: "snake_case display_name", + in: `{"name":"alice","display_name":"Alice","nip05":"alice@azzamo.net"}`, + name: "alice", dn: "Alice", nip05: "alice@azzamo.net", + }, + { + desc: "camelCase displayName preserved separately", + in: `{"displayName":"Alice"}`, + dnAlt: "Alice", + }, + { + desc: "deprecated username field exposed", + in: `{"username":"legacy"}`, + username: "legacy", + }, + { + desc: "all fields together", + in: `{"name":"a","display_name":"A","displayName":"AA","username":"u","nip05":"a@x"}`, + name: "a", dn: "A", dnAlt: "AA", username: "u", nip05: "a@x", + }, + } + for _, tc := range cases { + md, err := ParseMetadata(tc.in) + if err != nil { + t.Fatalf("%s: parse error: %v", tc.desc, err) + } + if md.Name != tc.name { + t.Errorf("%s: Name=%q want %q", tc.desc, md.Name, tc.name) + } + if md.DisplayName != tc.dn { + t.Errorf("%s: DisplayName=%q want %q", tc.desc, md.DisplayName, tc.dn) + } + if md.DisplayNameAlt != tc.dnAlt { + t.Errorf("%s: DisplayNameAlt=%q want %q", tc.desc, md.DisplayNameAlt, tc.dnAlt) + } + if md.Username != tc.username { + t.Errorf("%s: Username=%q want %q", tc.desc, md.Username, tc.username) + } + if md.NIP05 != tc.nip05 { + t.Errorf("%s: NIP05=%q want %q", tc.desc, md.NIP05, tc.nip05) + } + } +} + +func TestParseMetadataInvalidJSON(t *testing.T) { + if _, err := ParseMetadata("not json"); err == nil { + t.Fatal("expected error for invalid JSON") + } +} diff --git a/internal/nostr/publish.go b/internal/nostr/publish.go new file mode 100644 index 0000000..5977844 --- /dev/null +++ b/internal/nostr/publish.go @@ -0,0 +1,36 @@ +package nostr + +import ( + "context" + "errors" + + gn "github.com/nbd-wtf/go-nostr" +) + +var ErrNoRelayAccepted = errors.New("no relay accepted event") + +// Publish sends an already-signed event to all relays in the pool. +// Success if at least one relay accepts. +func Publish(ctx context.Context, p *Pool, ev *gn.Event) error { + var lastErr error + accepted := 0 + for _, url := range p.URLs() { + r, err := p.Connect(ctx, url) + if err != nil { + lastErr = err + continue + } + if err := r.Publish(ctx, *ev); err != nil { + lastErr = err + continue + } + accepted++ + } + if accepted == 0 { + if lastErr != nil { + return lastErr + } + return ErrNoRelayAccepted + } + return nil +} diff --git a/internal/nostr/relay.go b/internal/nostr/relay.go new file mode 100644 index 0000000..770bccc --- /dev/null +++ b/internal/nostr/relay.go @@ -0,0 +1,51 @@ +package nostr + +import ( + "context" + "sync" + "time" + + gn "github.com/nbd-wtf/go-nostr" +) + +// Pool is a small relay-connection pool. +type Pool struct { + mu sync.Mutex + relays []string + active map[string]*gn.Relay +} + +func NewPool(urls []string) *Pool { + return &Pool{relays: urls, active: map[string]*gn.Relay{}} +} + +func (p *Pool) URLs() []string { return p.relays } + +func (p *Pool) Connect(ctx context.Context, url string) (*gn.Relay, error) { + p.mu.Lock() + if r, ok := p.active[url]; ok && r.IsConnected() { + p.mu.Unlock() + return r, nil + } + p.mu.Unlock() + + dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + r, err := gn.RelayConnect(dialCtx, url) + if err != nil { + return nil, err + } + p.mu.Lock() + p.active[url] = r + p.mu.Unlock() + return r, nil +} + +func (p *Pool) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for _, r := range p.active { + _ = r.Close() + } + p.active = map[string]*gn.Relay{} +} diff --git a/internal/payments/dispatch.go b/internal/payments/dispatch.go new file mode 100644 index 0000000..fd983ae --- /dev/null +++ b/internal/payments/dispatch.go @@ -0,0 +1,69 @@ +package payments + +import ( + "context" + "log/slog" + "strconv" + "time" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +func (w *Worker) dispatchEvents(ctx context.Context, u *user.User, p *invoice.PendingInvoice, ev dm.EventType) { + vars := buildVars(u, w.domain, w.frontend) + if err := w.dms.Send(ctx, ev, u.Pubkey, vars); err != nil { + slog.Error("dm enqueue", "err", err) + } + data := map[string]any{ + "pubkey": u.Pubkey, + "npub": nostr.HexToNpub(u.Pubkey), + "username": u.Username, + "subscription_type": string(u.SubscriptionType), + "amount_sats": p.AmountSats, + "payment_hash": p.PaymentHash, + "is_renewal": p.IsRenewal, + } + if u.ExpiresAt != nil { + data["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) + } + if err := w.hooks.Enqueue(ctx, webhook.EventUserPaid, data); err != nil { + slog.Error("webhook enqueue", "err", err) + } + w.audit.Log(ctx, audit.ActionPaymentConfirmed, audit.ActorSystem, u.Pubkey, map[string]any{ + "payment_hash": p.PaymentHash, + "amount_sats": p.AmountSats, + "is_renewal": p.IsRenewal, + "event": string(ev), + }) + slog.Info("payment confirmed", + "pubkey", u.Pubkey, "username", u.Username, + "amount_sats", p.AmountSats, "renewal", p.IsRenewal) +} + +func buildVars(u *user.User, domain, frontend string) map[string]string { + expires := "lifetime" + days := "" + if u.ExpiresAt != nil { + expires = u.ExpiresAt.Format("2006-01-02") + d := int(time.Until(*u.ExpiresAt).Hours() / 24) + if d < 0 { + d = 0 + } + days = strconv.Itoa(d) + } + return map[string]string{ + "username": u.Username, + "npub": nostr.HexToNpub(u.Pubkey), + "pubkey": u.Pubkey, + "domain": domain, + "expires_at": expires, + "days_remaining": days, + "frontend_url": frontend, + "subscription_type": string(u.SubscriptionType), + } +} diff --git a/internal/payments/worker.go b/internal/payments/worker.go new file mode 100644 index 0000000..fc9a751 --- /dev/null +++ b/internal/payments/worker.go @@ -0,0 +1,173 @@ +package payments + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/noderunners/nip05api/internal/audit" + "github.com/noderunners/nip05api/internal/dm" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/user" + "github.com/noderunners/nip05api/internal/webhook" +) + +type Worker struct { + invoices *invoice.Service + users *user.Service + lnbits *invoice.LNbitsClient + dms *dm.Service + hooks *webhook.Service + audit *audit.Logger + domain string + frontend string + interval time.Duration + enabled bool +} + +func NewWorker(inv *invoice.Service, u *user.Service, ln *invoice.LNbitsClient, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger, domain, frontend string, enabled bool) *Worker { + return &Worker{ + invoices: inv, + users: u, + lnbits: ln, + dms: dms, + hooks: hooks, + audit: aud, + domain: domain, + frontend: frontend, + interval: 5 * time.Second, + enabled: enabled, + } +} + +func (w *Worker) Run(ctx context.Context) { + if !w.enabled { + <-ctx.Done() + return + } + t := time.NewTicker(w.interval) + defer t.Stop() + cleanup := time.NewTicker(15 * time.Minute) + defer cleanup.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.tick(ctx) + case <-cleanup.C: + _ = w.invoices.Repo().PurgeOldUnpaid(ctx) + } + } +} + +func (w *Worker) tick(ctx context.Context) { + pending, err := w.invoices.Repo().ListUnpaid(ctx) + if err != nil { + slog.Error("payments list", "err", err) + return + } + for _, p := range pending { + paid, err := w.lnbits.Status(ctx, p.PaymentHash) + if err != nil { + slog.Warn("lnbits status", "hash", p.PaymentHash, "err", err) + continue + } + if !paid { + continue + } + if err := w.confirm(ctx, p); err != nil { + slog.Error("confirm payment", "hash", p.PaymentHash, "err", err) + } + } +} + +// confirm completes a paid invoice idempotently. Crash recovery is safe at any +// point: we capture the target expiry once, then apply absolute updates. +func (w *Worker) confirm(ctx context.Context, p *invoice.PendingInvoice) error { + existing, getErr := w.users.Repo().GetByPubkey(ctx, p.Pubkey) + if getErr != nil && !errors.Is(getErr, user.ErrUserNotFound) { + return getErr + } + + target, err := w.resolveTarget(ctx, p, existing) + if err != nil { + return err + } + + wasNew := errors.Is(getErr, user.ErrUserNotFound) + if wasNew { + u := &user.User{ + Pubkey: p.Pubkey, + Username: p.Username, + SubscriptionType: p.SubscriptionType, + ExpiresAt: target, + IsActive: true, + } + if err := w.users.Repo().Insert(ctx, u); err != nil { + // Likely UNIQUE constraint from a concurrent recovery attempt; + // re-fetch and treat as existing. + existing2, err2 := w.users.Repo().GetByPubkey(ctx, p.Pubkey) + if err2 != nil { + return err + } + existing = existing2 + wasNew = false + } + } + if !wasNew { + if err := w.users.Repo().SetActiveExpiry(ctx, p.Pubkey, p.SubscriptionType, target); err != nil { + return err + } + } + + claimed, err := w.invoices.Repo().ClaimPaid(ctx, p.PaymentHash) + if err != nil { + return err + } + if !claimed { + return nil // another tick already dispatched events + } + + final, err := w.users.Repo().GetByPubkey(ctx, p.Pubkey) + if err != nil { + return err + } + + dmEvent := dm.EventWelcome + if !wasNew && p.IsRenewal { + dmEvent = dm.EventExtended + } + w.dispatchEvents(ctx, final, p, dmEvent) + return nil +} + +// resolveTarget returns the canonical expiry to apply to the user. Persisted +// on first call so retries see the same value. +func (w *Worker) resolveTarget(ctx context.Context, p *invoice.PendingInvoice, existing *user.User) (*time.Time, error) { + if p.TargetSet { + return p.TargetExpiresAt, nil + } + target := computeTarget(p, existing, time.Now().UTC()) + if _, err := w.invoices.Repo().SetTargetIfUnset(ctx, p.PaymentHash, target); err != nil { + return nil, err + } + fresh, err := w.invoices.Repo().Get(ctx, p.PaymentHash) + if err != nil { + return nil, err + } + return fresh.TargetExpiresAt, nil +} + +func computeTarget(p *invoice.PendingInvoice, existing *user.User, now time.Time) *time.Time { + if p.SubscriptionType == user.SubLifetime { + return nil + } + var current *time.Time + if existing != nil { + current = existing.ExpiresAt + } + return user.YearlyTargetExpiry(current, p.Years, now) +} + diff --git a/internal/payments/worker_test.go b/internal/payments/worker_test.go new file mode 100644 index 0000000..8f735ef --- /dev/null +++ b/internal/payments/worker_test.go @@ -0,0 +1,180 @@ +package payments + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/noderunners/nip05api/internal/db" + "github.com/noderunners/nip05api/internal/invoice" + "github.com/noderunners/nip05api/internal/user" +) + +const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + +func newTestDB(t *testing.T) *db.DB { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return d +} + +func TestComputeTarget_NewYearly(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1} + got := computeTarget(p, nil, now) + if got == nil || !got.Equal(now.AddDate(1, 0, 0)) { + t.Errorf("got %v want %v", got, now.AddDate(1, 0, 0)) + } +} + +func TestComputeTarget_RenewActive(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + current := now.AddDate(0, 6, 0) + p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1} + existing := &user.User{ExpiresAt: ¤t} + got := computeTarget(p, existing, now) + want := current.AddDate(1, 0, 0) + if !got.Equal(want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestComputeTarget_RenewExpired(t *testing.T) { + now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) + past := now.AddDate(0, -1, 0) + p := &invoice.PendingInvoice{SubscriptionType: user.SubYearly, Years: 1} + existing := &user.User{ExpiresAt: &past} + got := computeTarget(p, existing, now) + want := now.AddDate(1, 0, 0) + if !got.Equal(want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestComputeTarget_Lifetime(t *testing.T) { + now := time.Now() + p := &invoice.PendingInvoice{SubscriptionType: user.SubLifetime} + if got := computeTarget(p, nil, now); got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +// Idempotent confirm: applying the same target twice produces identical state. +func TestSetActiveExpiry_Idempotent(t *testing.T) { + d := newTestDB(t) + repo := user.NewRepo(d) + + expires := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC) + u := &user.User{ + Pubkey: testHex, + Username: "alice", + SubscriptionType: user.SubYearly, + ExpiresAt: &expires, + IsActive: true, + } + if err := repo.Insert(context.Background(), u); err != nil { + t.Fatal(err) + } + + target := time.Date(2028, 6, 1, 0, 0, 0, 0, time.UTC) + if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil { + t.Fatal(err) + } + got, err := repo.GetByPubkey(context.Background(), testHex) + if err != nil { + t.Fatal(err) + } + if !got.ExpiresAt.Equal(target) { + t.Errorf("first apply: got %v want %v", got.ExpiresAt, target) + } + + // Re-apply same target — must not advance further. + if err := repo.SetActiveExpiry(context.Background(), testHex, user.SubYearly, &target); err != nil { + t.Fatal(err) + } + got, _ = repo.GetByPubkey(context.Background(), testHex) + if !got.ExpiresAt.Equal(target) { + t.Errorf("re-apply changed value: got %v want %v", got.ExpiresAt, target) + } +} + +func TestSetTargetIfUnset_OnlyOnce(t *testing.T) { + d := newTestDB(t) + repo := invoice.NewRepo(d) + target1 := time.Date(2027, 6, 1, 0, 0, 0, 0, time.UTC) + + p := &invoice.PendingInvoice{ + PaymentHash: "hash1", + PaymentRequest: "lnbc1...", + Username: "alice", + Pubkey: testHex, + SubscriptionType: user.SubYearly, + Years: 1, + AmountSats: 1000, + ExpiresAt: time.Now().Add(30 * time.Minute), + } + if err := repo.Insert(context.Background(), p); err != nil { + t.Fatal(err) + } + + won, err := repo.SetTargetIfUnset(context.Background(), "hash1", &target1) + if err != nil || !won { + t.Fatalf("first set: won=%v err=%v", won, err) + } + + target2 := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + won, err = repo.SetTargetIfUnset(context.Background(), "hash1", &target2) + if err != nil { + t.Fatal(err) + } + if won { + t.Error("second set should be a no-op") + } + + fresh, err := repo.Get(context.Background(), "hash1") + if err != nil { + t.Fatal(err) + } + if !fresh.TargetSet || fresh.TargetExpiresAt == nil || !fresh.TargetExpiresAt.Equal(target1) { + t.Errorf("target should be the first value, got set=%v at=%v", + fresh.TargetSet, fresh.TargetExpiresAt) + } +} + +func TestClaimPaid_Atomic(t *testing.T) { + d := newTestDB(t) + repo := invoice.NewRepo(d) + + p := &invoice.PendingInvoice{ + PaymentHash: "hash2", + PaymentRequest: "lnbc...", + Username: "bob", + Pubkey: testHex, + SubscriptionType: user.SubYearly, + Years: 1, + AmountSats: 1000, + ExpiresAt: time.Now().Add(30 * time.Minute), + } + if err := repo.Insert(context.Background(), p); err != nil { + t.Fatal(err) + } + won1, err := repo.ClaimPaid(context.Background(), "hash2") + if err != nil || !won1 { + t.Fatalf("first claim: won=%v err=%v", won1, err) + } + won2, err := repo.ClaimPaid(context.Background(), "hash2") + if err != nil { + t.Fatal(err) + } + if won2 { + t.Error("second claim should lose race") + } +} diff --git a/internal/sync/worker.go b/internal/sync/worker.go new file mode 100644 index 0000000..0495548 --- /dev/null +++ b/internal/sync/worker.go @@ -0,0 +1,139 @@ +package sync + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/noderunners/nip05api/internal/nostr" + "github.com/noderunners/nip05api/internal/user" +) + +type Worker struct { + users *user.Service + pool *nostr.Pool + interval time.Duration + enabled bool + domain string + reserved []string +} + +func NewWorker(users *user.Service, pool *nostr.Pool, intervalMins int, enabled bool, domain string, reserved []string) *Worker { + if intervalMins <= 0 { + intervalMins = 15 + } + return &Worker{ + users: users, + pool: pool, + interval: time.Duration(intervalMins) * time.Minute, + enabled: enabled, + domain: domain, + reserved: reserved, + } +} + +func (w *Worker) Run(ctx context.Context) { + if !w.enabled { + <-ctx.Done() + return + } + t := time.NewTicker(w.interval) + defer t.Stop() + w.RunOnce(ctx) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.RunOnce(ctx) + } + } +} + +func (w *Worker) RunOnce(ctx context.Context) { + runCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + stale := time.Now().Add(-24 * time.Hour) + users, err := w.users.Repo().ListForSync(runCtx, stale) + if err != nil { + slog.Error("sync list", "err", err) + return + } + if len(users) == 0 { + return + } + slog.Info("profile sync starting", "count", len(users)) + updated := 0 + for _, u := range users { + if runCtx.Err() != nil { + break + } + if w.syncOne(runCtx, u) { + updated++ + } + } + slog.Info("profile sync complete", "updated", updated, "checked", len(users)) +} + +// syncOne fetches the latest kind:0 profile for u and rewrites the username +// when a better candidate is available. Returns true when the username was +// actually changed in the database. The user's last_synced_at is always +// touched so the worker doesn't re-poll the same pubkey on every tick. +func (w *Worker) syncOne(ctx context.Context, u *user.User) bool { + md, err := nostr.FetchMetadata(ctx, w.pool, u.Pubkey) + now := time.Now().UTC() + u.LastSyncedAt = &now + + touch := func(reason string, extra ...any) { + if err := w.users.Repo().Update(ctx, u); err != nil { + slog.Error("sync update", "pubkey", u.Pubkey, "err", err) + return + } + args := append([]any{"pubkey", u.Pubkey, "reason", reason}, extra...) + slog.Debug("profile sync skipped", args...) + } + + if err != nil { + touch("fetch_error", "err", err) + return false + } + if md == nil { + touch("no_metadata") + return false + } + + candidate := user.CandidateFromMetadata( + md.Name, md.DisplayName, md.DisplayNameAlt, md.Username, + md.NIP05, w.domain, w.reserved, + ) + if candidate == "" { + touch("empty_candidate") + return false + } + if candidate == u.Username { + touch("unchanged") + return false + } + + other, err := w.users.Repo().GetByUsername(ctx, candidate) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + touch("lookup_error", "err", err) + return false + } + if other != nil && other.Pubkey != u.Pubkey { + touch("taken", "candidate", candidate) + return false + } + + previous := u.Username + u.Username = candidate + if err := w.users.Repo().Update(ctx, u); err != nil { + slog.Error("sync update", "pubkey", u.Pubkey, "err", err) + return false + } + slog.Info("profile sync username updated", + "pubkey", u.Pubkey, "from", previous, "to", candidate) + return true +} diff --git a/internal/user/model.go b/internal/user/model.go new file mode 100644 index 0000000..18565f8 --- /dev/null +++ b/internal/user/model.go @@ -0,0 +1,74 @@ +package user + +import ( + "errors" + "regexp" + "strings" + "time" +) + +type SubscriptionType string + +const ( + SubYearly SubscriptionType = "yearly" + SubLifetime SubscriptionType = "lifetime" +) + +func (s SubscriptionType) Valid() bool { + return s == SubYearly || s == SubLifetime +} + +type User struct { + ID int64 + Pubkey string + Username string + SubscriptionType SubscriptionType + ExpiresAt *time.Time + IsActive bool + ManualUsername bool + LastSyncedAt *time.Time + ExpiringReminderSentAt *time.Time + DeactivatedAt *time.Time + CreatedAt time.Time +} + +func (u *User) IsLifetime() bool { return u.SubscriptionType == SubLifetime } + +func (u *User) InGrace() bool { return !u.IsActive && u.DeactivatedAt != nil } + +var ( + ErrInvalidUsername = errors.New("invalid username") + ErrUserNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username taken") +) + +// Username rules: 1-30 chars, [a-z0-9_-], lowercase, must start with alnum. +var usernameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,29}$`) + +func ValidateUsername(name string, reserved []string) error { + name = strings.ToLower(strings.TrimSpace(name)) + if !usernameRE.MatchString(name) { + return ErrInvalidUsername + } + for _, r := range reserved { + if strings.EqualFold(name, strings.TrimSpace(r)) { + return ErrInvalidUsername + } + } + return nil +} + +func NormalizeUsername(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// ProvisionalUsername returns a deterministic placeholder handle for a pubkey +// when the caller did not supply one. The format `u_` keeps the +// result inside the 30-char username rule and matches usernameRE. +func ProvisionalUsername(pubkey string) string { + hex := strings.ToLower(strings.TrimSpace(pubkey)) + if len(hex) > 16 { + hex = hex[:16] + } + return "u_" + hex +} diff --git a/internal/user/model_test.go b/internal/user/model_test.go new file mode 100644 index 0000000..8ed52d2 --- /dev/null +++ b/internal/user/model_test.go @@ -0,0 +1,55 @@ +package user + +import "testing" + +func TestValidateUsername(t *testing.T) { + cases := []struct { + name string + ok bool + }{ + {"alice", true}, + {"al-ice_42", true}, + {"a", true}, + {"", false}, + {"-alice", false}, + {"_alice", false}, + {"thisusernameiswaytoolongtobevalid12345", false}, + {"admin", false}, + } + reserved := []string{"admin", "root"} + for _, tc := range cases { + err := ValidateUsername(tc.name, reserved) + if tc.ok && err != nil { + t.Errorf("%q expected ok, got %v", tc.name, err) + } + if !tc.ok && err == nil { + t.Errorf("%q expected fail", tc.name) + } + } +} + +func TestProvisionalUsername(t *testing.T) { + const pk = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + got := ProvisionalUsername(pk) + want := "u_0e8c41ebcd55a8d8" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + if err := ValidateUsername(got, nil); err != nil { + t.Fatalf("provisional name should validate: %v", err) + } + + short := ProvisionalUsername("AbC") + if short != "u_abc" { + t.Errorf("expected lowercase trimmed prefix, got %q", short) + } +} + +func TestSubscriptionType(t *testing.T) { + if !SubYearly.Valid() || !SubLifetime.Valid() { + t.Fatal("valid types reported invalid") + } + if SubscriptionType("monthly").Valid() { + t.Fatal("invalid type reported valid") + } +} diff --git a/internal/user/nostr_sync.go b/internal/user/nostr_sync.go new file mode 100644 index 0000000..e448d00 --- /dev/null +++ b/internal/user/nostr_sync.go @@ -0,0 +1,101 @@ +package user + +import ( + "strings" +) + +// SanitizeForUsername coerces an arbitrary profile string into a candidate +// that matches usernameRE: lowercase ASCII alphanumerics, `_`, and `-`, +// length <= 30, with an alphanumeric first character. Returns "" when no +// usable handle can be derived. +func SanitizeForUsername(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + prevSep := false + for _, r := range s { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + prevSep = false + case r == '-' || r == '_': + if b.Len() == 0 { + continue + } + if prevSep { + continue + } + b.WriteRune(r) + prevSep = true + default: + if b.Len() == 0 { + continue + } + if prevSep { + continue + } + b.WriteRune('_') + prevSep = true + } + } + out := strings.TrimRight(b.String(), "_-") + if len(out) > 30 { + out = strings.TrimRight(out[:30], "_-") + } + return out +} + +// nip05LocalPart returns the local part of a NIP-05 identifier (`local@domain`) +// when its domain matches `serviceDomain` (case-insensitive). The bare form +// `local` (no `@`) per NIP-05 implies `_@domain`, which we ignore for sync +// since we cannot prove the domain match. +func nip05LocalPart(nip05, serviceDomain string) string { + nip05 = strings.TrimSpace(nip05) + if nip05 == "" || serviceDomain == "" { + return "" + } + at := strings.LastIndex(nip05, "@") + if at <= 0 || at == len(nip05)-1 { + return "" + } + local := nip05[:at] + domain := nip05[at+1:] + if !strings.EqualFold(domain, serviceDomain) { + return "" + } + return local +} + +// CandidateFromMetadata returns a sanitized, validated username derived from a +// kind:0 profile, or "" if no field yields a usable handle. Precedence: +// 1. NIP-05 local part when its domain matches serviceDomain. +// 2. `name` (NIP-01). +// 3. `display_name` / `displayName` (NIP-24). +// 4. Deprecated `username` field. +func CandidateFromMetadata(name, displayName, displayNameAlt, username, nip05, serviceDomain string, reserved []string) string { + tryFields := []string{ + nip05LocalPart(nip05, serviceDomain), + name, + displayName, + displayNameAlt, + username, + } + for _, raw := range tryFields { + if raw == "" { + continue + } + c := SanitizeForUsername(raw) + if c == "" { + continue + } + if err := ValidateUsername(c, reserved); err != nil { + continue + } + return c + } + return "" +} diff --git a/internal/user/nostr_sync_test.go b/internal/user/nostr_sync_test.go new file mode 100644 index 0000000..1ad0236 --- /dev/null +++ b/internal/user/nostr_sync_test.go @@ -0,0 +1,119 @@ +package user + +import "testing" + +func TestSanitizeForUsername(t *testing.T) { + cases := []struct { + in, want string + }{ + {"alice", "alice"}, + {"Alice", "alice"}, + {" Alice ", "alice"}, + {"Alice Bob", "alice_bob"}, + {"alice bob", "alice_bob"}, + {"Alice!@#Bob", "alice_bob"}, + {"-alice", "alice"}, + {"_alice", "alice"}, + {"alice_", "alice"}, + {"alice--bob", "alice-bob"}, + {"alice__bob", "alice_bob"}, + {" ", ""}, + {"", ""}, + {"!!!", ""}, + {"日本語", ""}, + {"alice日本", "alice"}, + {"thisusernameiswaytoolongtobevalid12345", "thisusernameiswaytoolongtobeva"}, + } + for _, tc := range cases { + got := SanitizeForUsername(tc.in) + if got != tc.want { + t.Errorf("SanitizeForUsername(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSanitizeForUsernamePassesValidate(t *testing.T) { + inputs := []string{"Alice Bob", " S0me User! ", "alice--bob"} + for _, in := range inputs { + s := SanitizeForUsername(in) + if s == "" { + t.Fatalf("unexpected empty sanitize for %q", in) + } + if err := ValidateUsername(s, nil); err != nil { + t.Errorf("sanitized %q -> %q failed ValidateUsername: %v", in, s, err) + } + } +} + +func TestCandidateFromMetadataPrecedence(t *testing.T) { + const domain = "azzamo.net" + reserved := []string{"admin"} + + cases := []struct { + desc string + name, dn, dnAlt, username, nip05, dom string + want string + }{ + { + desc: "nip05 local part wins when domain matches", + name: "alice", nip05: "preferred@azzamo.net", dom: domain, + want: "preferred", + }, + { + desc: "nip05 ignored when domain differs", + name: "alice", nip05: "preferred@other.example", dom: domain, + want: "alice", + }, + { + desc: "name takes precedence over display_name", + name: "alice", dn: "Bob Builder", dom: domain, + want: "alice", + }, + { + desc: "falls back to display_name when name empty", + dn: "Bob Builder", dom: domain, + want: "bob_builder", + }, + { + desc: "falls back to camelCase displayName when others empty", + dnAlt: "Bob B", dom: domain, + want: "bob_b", + }, + { + desc: "falls back to deprecated username last", + username: "legacy_user", dom: domain, + want: "legacy_user", + }, + { + desc: "skips fields that sanitize to empty", + name: "!!!", dn: "Real Name", dom: domain, + want: "real_name", + }, + { + desc: "skips reserved words and falls through", + name: "admin", dn: "Real Name", dom: domain, + want: "real_name", + }, + { + desc: "no usable field returns empty", + name: "!!!", dom: domain, + want: "", + }, + { + desc: "bare nip05 with no @ is ignored", + nip05: "alice", name: "fallback", dom: domain, + want: "fallback", + }, + { + desc: "empty service domain ignores nip05", + name: "alice", nip05: "preferred@azzamo.net", + want: "alice", + }, + } + for _, tc := range cases { + got := CandidateFromMetadata(tc.name, tc.dn, tc.dnAlt, tc.username, tc.nip05, tc.dom, reserved) + if got != tc.want { + t.Errorf("%s: got %q want %q", tc.desc, got, tc.want) + } + } +} diff --git a/internal/user/repo.go b/internal/user/repo.go new file mode 100644 index 0000000..8fa067a --- /dev/null +++ b/internal/user/repo.go @@ -0,0 +1,150 @@ +package user + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +type Repo struct{ db *db.DB } + +func NewRepo(d *db.DB) *Repo { return &Repo{db: d} } + +func parseTime(s sql.NullString) *time.Time { + if !s.Valid || s.String == "" { + return nil + } + t, err := time.Parse(time.RFC3339, s.String) + if err != nil { + t, err = time.Parse("2006-01-02 15:04:05", s.String) + if err != nil { + return nil + } + } + return &t +} + +func mustParseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err == nil { + return t + } + t, err = time.Parse("2006-01-02 15:04:05", s) + if err == nil { + return t + } + return time.Time{} +} + +func formatTime(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} + +func scanUser(row interface { + Scan(dest ...any) error +}) (*User, error) { + var u User + var sub string + var expiresAt, lastSynced, reminderSent, deactivatedAt, createdAt sql.NullString + if err := row.Scan( + &u.ID, &u.Pubkey, &u.Username, &sub, + &expiresAt, &u.IsActive, &u.ManualUsername, + &lastSynced, &reminderSent, &deactivatedAt, &createdAt, + ); err != nil { + return nil, err + } + u.SubscriptionType = SubscriptionType(sub) + u.ExpiresAt = parseTime(expiresAt) + u.LastSyncedAt = parseTime(lastSynced) + u.ExpiringReminderSentAt = parseTime(reminderSent) + u.DeactivatedAt = parseTime(deactivatedAt) + if t := parseTime(createdAt); t != nil { + u.CreatedAt = *t + } + return &u, nil +} + +const userCols = `id, pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, expiring_reminder_sent_at, deactivated_at, created_at` + +func (r *Repo) GetByPubkey(ctx context.Context, pubkey string) (*User, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE pubkey = ?`, pubkey) + u, err := scanUser(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return u, err +} + +func (r *Repo) GetByUsername(ctx context.Context, username string) (*User, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, username) + u, err := scanUser(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return u, err +} + +func (r *Repo) Insert(ctx context.Context, u *User) error { + now := time.Now().UTC() + res, err := r.db.ExecContext(ctx, `INSERT INTO users + (pubkey, username, subscription_type, expires_at, is_active, manual_username, created_at) + VALUES (?, ?, ?, ?, 1, ?, ?)`, + u.Pubkey, u.Username, string(u.SubscriptionType), + formatTime(u.ExpiresAt), u.ManualUsername, + now.Format(time.RFC3339)) + if err != nil { + return err + } + id, err := res.LastInsertId() + if err != nil { + return err + } + u.ID = id + u.CreatedAt = now + return nil +} + +func (r *Repo) Update(ctx context.Context, u *User) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET + username = ?, + subscription_type = ?, + expires_at = ?, + is_active = ?, + manual_username = ?, + last_synced_at = ?, + expiring_reminder_sent_at = ?, + deactivated_at = ? + WHERE pubkey = ?`, + u.Username, string(u.SubscriptionType), + formatTime(u.ExpiresAt), u.IsActive, u.ManualUsername, + formatTime(u.LastSyncedAt), formatTime(u.ExpiringReminderSentAt), + formatTime(u.DeactivatedAt), u.Pubkey) + return err +} + +func (r *Repo) Delete(ctx context.Context, pubkey string) error { + _, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE pubkey = ?`, pubkey) + return err +} + +// SetActiveExpiry sets a user's expires_at to an absolute value and reactivates +// them. Idempotent: applying the same input twice produces the same end state. +func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error { + _, err := r.db.ExecContext(ctx, `UPDATE users SET + is_active = 1, + deactivated_at = NULL, + expiring_reminder_sent_at = NULL, + subscription_type = ?, + expires_at = ? + WHERE pubkey = ?`, + string(sub), formatTime(expiresAt), pubkey) + return err +} + + diff --git a/internal/user/repo_query.go b/internal/user/repo_query.go new file mode 100644 index 0000000..a2e5e39 --- /dev/null +++ b/internal/user/repo_query.go @@ -0,0 +1,115 @@ +package user + +import ( + "context" + "time" +) + +type ListFilter struct { + ActiveOnly bool + Search string + Limit int +} + +func (r *Repo) List(ctx context.Context, f ListFilter) ([]*User, error) { + q := `SELECT ` + userCols + ` FROM users WHERE 1=1` + args := []any{} + if f.ActiveOnly { + q += ` AND is_active = 1` + } + if f.Search != "" { + q += ` AND (username LIKE ? COLLATE NOCASE OR pubkey LIKE ?)` + args = append(args, "%"+f.Search+"%", "%"+f.Search+"%") + } + q += ` ORDER BY created_at DESC` + if f.Limit > 0 { + q += ` LIMIT ?` + args = append(args, f.Limit) + } + rows, err := r.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + return r.collect(rows) +} + +func (r *Repo) ActiveByName(ctx context.Context) (map[string]string, error) { + rows, err := r.db.QueryContext(ctx, `SELECT username, pubkey FROM users WHERE is_active = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var u, p string + if err := rows.Scan(&u, &p); err != nil { + return nil, err + } + out[u] = p + } + return out, rows.Err() +} + +func (r *Repo) collect(rows interface { + Next() bool + Scan(...any) error + Err() error + Close() error +}) ([]*User, error) { + defer rows.Close() + out := []*User{} + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (r *Repo) ListPendingReminders(ctx context.Context, days int, now time.Time) ([]*User, error) { + low := now.Add(time.Duration(days)*24*time.Hour - 12*time.Hour).UTC().Format(time.RFC3339) + high := now.Add(time.Duration(days)*24*time.Hour + 12*time.Hour).UTC().Format(time.RFC3339) + rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users + WHERE is_active = 1 + AND subscription_type = 'yearly' + AND expires_at BETWEEN ? AND ? + AND (expiring_reminder_sent_at IS NULL OR expiring_reminder_sent_at < ?)`, + low, high, now.Add(-23*time.Hour).UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + return r.collect(rows) +} + +func (r *Repo) ListExpired(ctx context.Context, now time.Time) ([]*User, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users + WHERE is_active = 1 AND subscription_type = 'yearly' AND expires_at < ?`, + now.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + return r.collect(rows) +} + +func (r *Repo) ListGraceExpired(ctx context.Context, cutoff time.Time) ([]*User, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users + WHERE is_active = 0 AND deactivated_at IS NOT NULL AND deactivated_at < ?`, + cutoff.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + return r.collect(rows) +} + +func (r *Repo) ListForSync(ctx context.Context, staleBefore time.Time) ([]*User, error) { + rows, err := r.db.QueryContext(ctx, `SELECT `+userCols+` FROM users + WHERE is_active = 1 AND manual_username = 0 + AND (last_synced_at IS NULL OR last_synced_at < ?)`, + staleBefore.UTC().Format(time.RFC3339)) + if err != nil { + return nil, err + } + return r.collect(rows) +} diff --git a/internal/user/repo_test.go b/internal/user/repo_test.go new file mode 100644 index 0000000..ec2d96a --- /dev/null +++ b/internal/user/repo_test.go @@ -0,0 +1,56 @@ +package user + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +func newTestRepo(t *testing.T) *Repo { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + return NewRepo(d) +} + +func TestRepo_InsertAndFetch(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + exp := time.Now().Add(365 * 24 * time.Hour) + u := &User{ + Pubkey: "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c", + Username: "alice", + SubscriptionType: SubYearly, + ExpiresAt: &exp, + IsActive: true, + } + if err := repo.Insert(ctx, u); err != nil { + t.Fatalf("insert: %v", err) + } + got, err := repo.GetByPubkey(ctx, u.Pubkey) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Username != "alice" { + t.Errorf("got username %q", got.Username) + } + if !got.IsActive { + t.Error("expected active") + } +} + +func TestRepo_GetByUsername_NotFound(t *testing.T) { + repo := newTestRepo(t) + if _, err := repo.GetByUsername(context.Background(), "nope"); err != ErrUserNotFound { + t.Errorf("got %v want ErrUserNotFound", err) + } +} diff --git a/internal/user/service.go b/internal/user/service.go new file mode 100644 index 0000000..bbdd300 --- /dev/null +++ b/internal/user/service.go @@ -0,0 +1,126 @@ +package user + +import ( + "context" + "errors" + "time" +) + +type Service struct { + repo *Repo + reserved []string +} + +func NewService(repo *Repo, reserved []string) *Service { + return &Service{repo: repo, reserved: reserved} +} + +func (s *Service) Repo() *Repo { return s.repo } + +func (s *Service) Reserved() []string { return s.reserved } + +// IsAvailable returns true if no active/in-grace user has the username. +func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error) { + if err := ValidateUsername(username, s.reserved); err != nil { + return false, err + } + u, err := s.repo.GetByUsername(ctx, NormalizeUsername(username)) + if errors.Is(err, ErrUserNotFound) { + return true, nil + } + if err != nil { + return false, err + } + _ = u + return false, nil +} + +// CreateOrActivate inserts a new active user. Caller is responsible for transactional +// concerns (e.g. payments worker uses this within a tx). +func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) { + username = NormalizeUsername(username) + if err := ValidateUsername(username, s.reserved); err != nil { + return nil, err + } + now := time.Now().UTC() + expiresAt := computeExpiry(sub, years, time.Time{}, now) + + u := &User{ + Pubkey: pubkey, + Username: username, + SubscriptionType: sub, + ExpiresAt: expiresAt, + IsActive: true, + ManualUsername: manual, + } + if err := s.repo.Insert(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +// Renew extends an existing user. Used by payments worker and admin extend. +func (s *Service) Renew(ctx context.Context, u *User, sub SubscriptionType, years int) error { + now := time.Now().UTC() + var current time.Time + if u.ExpiresAt != nil { + current = *u.ExpiresAt + } + u.SubscriptionType = sub + u.ExpiresAt = computeExpiry(sub, years, current, now) + u.IsActive = true + u.DeactivatedAt = nil + u.ExpiringReminderSentAt = nil + return s.repo.Update(ctx, u) +} + +func (s *Service) SetUsername(ctx context.Context, pubkey, username string) (*User, error) { + username = NormalizeUsername(username) + if err := ValidateUsername(username, s.reserved); err != nil { + return nil, err + } + u, err := s.repo.GetByPubkey(ctx, pubkey) + if err != nil { + return nil, err + } + if existing, err := s.repo.GetByUsername(ctx, username); err == nil && existing.Pubkey != pubkey { + return nil, ErrUsernameTaken + } + u.Username = username + u.ManualUsername = true + if err := s.repo.Update(ctx, u); err != nil { + return nil, err + } + return u, nil +} + +func (s *Service) Delete(ctx context.Context, pubkey string) error { + return s.repo.Delete(ctx, pubkey) +} + +// computeExpiry returns *time.Time (nil for lifetime). +func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time { + if sub == SubLifetime { + return nil + } + var cur time.Time + if !current.IsZero() { + cur = current + } + return YearlyTargetExpiry(&cur, years, now) +} + +// YearlyTargetExpiry computes the new expiry for a yearly subscription using +// effective_start = max(now, current_expiry); new_expiry = effective_start + years. +// currentExpiry may be nil or zero for first-time purchases. +func YearlyTargetExpiry(currentExpiry *time.Time, years int, now time.Time) *time.Time { + if years <= 0 { + years = 1 + } + base := now + if currentExpiry != nil && currentExpiry.After(base) { + base = *currentExpiry + } + t := base.AddDate(years, 0, 0) + return &t +} diff --git a/internal/user/service_test.go b/internal/user/service_test.go new file mode 100644 index 0000000..3bd6561 --- /dev/null +++ b/internal/user/service_test.go @@ -0,0 +1,46 @@ +package user + +import ( + "testing" + "time" +) + +func TestComputeExpiry_NewYearly(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + exp := computeExpiry(SubYearly, 1, time.Time{}, now) + if exp == nil { + t.Fatal("nil expiry") + } + want := now.AddDate(1, 0, 0) + if !exp.Equal(want) { + t.Errorf("got %v want %v", exp, want) + } +} + +func TestComputeExpiry_RenewActive(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + current := now.AddDate(0, 6, 0) + exp := computeExpiry(SubYearly, 1, current, now) + want := current.AddDate(1, 0, 0) + if !exp.Equal(want) { + t.Errorf("active renew: got %v want %v", exp, want) + } +} + +func TestComputeExpiry_RenewExpired(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + past := now.AddDate(0, -1, 0) + exp := computeExpiry(SubYearly, 1, past, now) + want := now.AddDate(1, 0, 0) + if !exp.Equal(want) { + t.Errorf("expired renew: got %v want %v", exp, want) + } +} + +func TestComputeExpiry_Lifetime(t *testing.T) { + now := time.Now() + exp := computeExpiry(SubLifetime, 1, time.Time{}, now) + if exp != nil { + t.Errorf("lifetime should be nil, got %v", exp) + } +} diff --git a/internal/webhook/model.go b/internal/webhook/model.go new file mode 100644 index 0000000..a0e70b5 --- /dev/null +++ b/internal/webhook/model.go @@ -0,0 +1,39 @@ +package webhook + +import "time" + +type EventType string + +const ( + EventUserPaid EventType = "user.paid" + EventUserAdded EventType = "user.added" + EventUserExtended EventType = "user.extended" + EventUserRemoved EventType = "user.removed" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusDelivered Status = "delivered" + StatusDead Status = "dead" +) + +type Payload struct { + Event EventType `json:"event"` + Timestamp string `json:"timestamp"` + Domain string `json:"domain"` + Data map[string]any `json:"data"` +} + +type OutboxItem struct { + ID int64 + EventType EventType + Payload string + Attempts int + LastAttemptAt *time.Time + NextAttemptAt time.Time + Status Status + LastError string + CreatedAt time.Time +} diff --git a/internal/webhook/repo.go b/internal/webhook/repo.go new file mode 100644 index 0000000..e8efafd --- /dev/null +++ b/internal/webhook/repo.go @@ -0,0 +1,86 @@ +package webhook + +import ( + "context" + "database/sql" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +type Repo struct{ db *db.DB } + +func NewRepo(d *db.DB) *Repo { return &Repo{db: d} } + +func (r *Repo) Insert(ctx context.Context, eventType EventType, payload string) error { + _, err := r.db.ExecContext(ctx, `INSERT INTO webhook_outbox + (event_type, payload, next_attempt_at) VALUES (?, ?, ?)`, + string(eventType), payload, time.Now().UTC().Format(time.RFC3339)) + return err +} + +func (r *Repo) Claim(ctx context.Context, limit int) ([]*OutboxItem, error) { + rows, err := r.db.QueryContext(ctx, `SELECT id, event_type, payload, attempts, + last_attempt_at, next_attempt_at, status, COALESCE(last_error, ''), created_at + FROM webhook_outbox + WHERE status = 'pending' AND next_attempt_at <= ? + ORDER BY next_attempt_at ASC LIMIT ?`, + time.Now().UTC().Format(time.RFC3339), limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []*OutboxItem{} + for rows.Next() { + var it OutboxItem + var status, eventType string + var lastAttempt, nextAttempt, created sql.NullString + if err := rows.Scan(&it.ID, &eventType, &it.Payload, &it.Attempts, + &lastAttempt, &nextAttempt, &status, &it.LastError, &created); err != nil { + return nil, err + } + it.EventType = EventType(eventType) + it.Status = Status(status) + if lastAttempt.Valid { + if t, err := time.Parse(time.RFC3339, lastAttempt.String); err == nil { + it.LastAttemptAt = &t + } + } + if nextAttempt.Valid { + if t, err := time.Parse(time.RFC3339, nextAttempt.String); err == nil { + it.NextAttemptAt = t + } + } + if created.Valid { + if t, err := time.Parse(time.RFC3339, created.String); err == nil { + it.CreatedAt = t + } else if t, err := time.Parse("2006-01-02 15:04:05", created.String); err == nil { + it.CreatedAt = t + } + } + out = append(out, &it) + } + return out, rows.Err() +} + +func (r *Repo) MarkDelivered(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET status = 'delivered', + last_attempt_at = ?, last_error = '' WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), id) + return err +} + +func (r *Repo) MarkRetry(ctx context.Context, id int64, attempts int, nextAt time.Time, errMsg string) error { + _, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET attempts = ?, + last_attempt_at = ?, next_attempt_at = ?, last_error = ? WHERE id = ?`, + attempts, time.Now().UTC().Format(time.RFC3339), + nextAt.UTC().Format(time.RFC3339), errMsg, id) + return err +} + +func (r *Repo) MarkDead(ctx context.Context, id int64, errMsg string) error { + _, err := r.db.ExecContext(ctx, `UPDATE webhook_outbox SET status = 'dead', + last_attempt_at = ?, last_error = ? WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), errMsg, id) + return err +} diff --git a/internal/webhook/service.go b/internal/webhook/service.go new file mode 100644 index 0000000..8ccad50 --- /dev/null +++ b/internal/webhook/service.go @@ -0,0 +1,36 @@ +package webhook + +import ( + "context" + "encoding/json" + "time" +) + +type Service struct { + repo *Repo + domain string + enabled bool +} + +func NewService(repo *Repo, domain string, enabled bool) *Service { + return &Service{repo: repo, domain: domain, enabled: enabled} +} + +func (s *Service) Enabled() bool { return s.enabled } + +func (s *Service) Enqueue(ctx context.Context, event EventType, data map[string]any) error { + if !s.enabled { + return nil + } + p := Payload{ + Event: event, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Domain: s.domain, + Data: data, + } + b, err := json.Marshal(p) + if err != nil { + return err + } + return s.repo.Insert(ctx, event, string(b)) +} diff --git a/internal/webhook/signer.go b/internal/webhook/signer.go new file mode 100644 index 0000000..0a4c8b8 --- /dev/null +++ b/internal/webhook/signer.go @@ -0,0 +1,16 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +func Sign(secret string, body []byte) string { + if secret == "" { + return "" + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/webhook/signer_test.go b/internal/webhook/signer_test.go new file mode 100644 index 0000000..7ff41e7 --- /dev/null +++ b/internal/webhook/signer_test.go @@ -0,0 +1,21 @@ +package webhook + +import "testing" + +func TestSign(t *testing.T) { + if Sign("", []byte("hello")) != "" { + t.Error("empty secret should return empty signature") + } + sig := Sign("supersecret", []byte("hello")) + if len(sig) != 64 { + t.Errorf("expected 64-char hex hmac, got %d: %q", len(sig), sig) + } + again := Sign("supersecret", []byte("hello")) + if sig != again { + t.Error("signature should be deterministic") + } + other := Sign("different", []byte("hello")) + if sig == other { + t.Error("different secret should produce different signature") + } +} diff --git a/internal/webhook/worker.go b/internal/webhook/worker.go new file mode 100644 index 0000000..197e089 --- /dev/null +++ b/internal/webhook/worker.go @@ -0,0 +1,123 @@ +package webhook + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +var retrySchedule = []time.Duration{ + 30 * time.Second, + 2 * time.Minute, + 10 * time.Minute, + 1 * time.Hour, + 6 * time.Hour, +} + +type Worker struct { + repo *Repo + url string + secret string + timeout time.Duration + maxRetries int + hc *http.Client +} + +func NewWorker(repo *Repo, url, secret string, timeoutSecs, maxRetries int) *Worker { + if maxRetries <= 0 { + maxRetries = len(retrySchedule) + } + return &Worker{ + repo: repo, + url: url, + secret: secret, + timeout: time.Duration(timeoutSecs) * time.Second, + maxRetries: maxRetries, + hc: &http.Client{Timeout: time.Duration(timeoutSecs) * time.Second}, + } +} + +func (w *Worker) Run(ctx context.Context) { + if w.url == "" { + <-ctx.Done() + return + } + t := time.NewTicker(1 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + w.tick(ctx) + } + } +} + +func (w *Worker) tick(ctx context.Context) { + items, err := w.repo.Claim(ctx, 5) + if err != nil { + slog.Error("webhook claim", "err", err) + return + } + if len(items) == 0 { + return + } + var wg sync.WaitGroup + for _, it := range items { + wg.Add(1) + go func(it *OutboxItem) { + defer wg.Done() + w.deliver(ctx, it) + }(it) + } + wg.Wait() +} + +func (w *Worker) deliver(ctx context.Context, it *OutboxItem) { + body := []byte(it.Payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body)) + if err != nil { + w.handleErr(ctx, it, err) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "nip05api/1.0") + req.Header.Set("X-Webhook-Event", string(it.EventType)) + if sig := Sign(w.secret, body); sig != "" { + req.Header.Set("X-Webhook-Signature", sig) + } + + resp, err := w.hc.Do(req) + if err != nil { + w.handleErr(ctx, it, err) + return + } + defer resp.Body.Close() + if resp.StatusCode/100 == 2 { + _ = w.repo.MarkDelivered(ctx, it.ID) + slog.Info("webhook delivered", "event", it.EventType, "id", it.ID) + return + } + w.handleErr(ctx, it, fmt.Errorf("status %d", resp.StatusCode)) +} + +func (w *Worker) handleErr(ctx context.Context, it *OutboxItem, err error) { + attempts := it.Attempts + 1 + if attempts >= w.maxRetries { + _ = w.repo.MarkDead(ctx, it.ID, err.Error()) + slog.Error("webhook dead", "id", it.ID, "err", err) + return + } + idx := attempts - 1 + if idx >= len(retrySchedule) { + idx = len(retrySchedule) - 1 + } + next := time.Now().UTC().Add(retrySchedule[idx]) + _ = w.repo.MarkRetry(ctx, it.ID, attempts, next, err.Error()) + slog.Warn("webhook retry", "id", it.ID, "attempts", attempts, "next", next, "err", err) +} diff --git a/internal/webhook/worker_test.go b/internal/webhook/worker_test.go new file mode 100644 index 0000000..b327793 --- /dev/null +++ b/internal/webhook/worker_test.go @@ -0,0 +1,179 @@ +package webhook + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/noderunners/nip05api/internal/db" +) + +func setupRepo(t *testing.T) *Repo { + t.Helper() + d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := d.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.Close() }) + return NewRepo(d) +} + +func TestWorker_DeliversAndMarksDelivered(t *testing.T) { + repo := setupRepo(t) + var ( + mu sync.Mutex + hits int32 + gotSig string + gotEvent string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + mu.Lock() + gotSig = r.Header.Get("X-Webhook-Signature") + gotEvent = r.Header.Get("X-Webhook-Event") + mu.Unlock() + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(srv.Close) + + svc := NewService(repo, "test.local", true) + if err := svc.Enqueue(context.Background(), EventUserPaid, map[string]any{"username": "alice"}); err != nil { + t.Fatal(err) + } + + w := &Worker{ + repo: repo, + url: srv.URL, + secret: "topsecret", + timeout: 2 * time.Second, + maxRetries: 5, + hc: &http.Client{Timeout: 2 * time.Second}, + } + w.tick(context.Background()) + + if atomic.LoadInt32(&hits) == 0 { + t.Fatal("webhook never received") + } + mu.Lock() + defer mu.Unlock() + if gotEvent != "user.paid" { + t.Errorf("event header: %q", gotEvent) + } + if len(gotSig) != 64 { + t.Errorf("signature header: %q", gotSig) + } + + // Row should be marked delivered. + var status string + if err := repo.db.QueryRowContext(context.Background(), + `SELECT status FROM webhook_outbox LIMIT 1`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != string(StatusDelivered) { + t.Errorf("expected delivered, got %s", status) + } +} + +func TestWorker_RetriesOn5xx(t *testing.T) { + repo := setupRepo(t) + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + svc := NewService(repo, "test.local", true) + if err := svc.Enqueue(context.Background(), EventUserAdded, map[string]any{}); err != nil { + t.Fatal(err) + } + + // First attempt fires immediately (next_attempt_at = now). + w := &Worker{ + repo: repo, + url: srv.URL, + timeout: 1 * time.Second, + maxRetries: 5, + hc: &http.Client{Timeout: 1 * time.Second}, + } + + w.tick(context.Background()) + + if atomic.LoadInt32(&hits) != 1 { + t.Fatalf("expected 1 attempt, got %d", hits) + } + + // Verify row updated with attempts=1, status still pending. + rows, err := repo.Claim(context.Background(), 10) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Errorf("row should not be claimable yet (next_attempt_at in future), got %d", len(rows)) + } + + var attempts int + if err := repo.db.QueryRowContext(context.Background(), + `SELECT attempts FROM webhook_outbox LIMIT 1`).Scan(&attempts); err != nil { + t.Fatal(err) + } + if attempts != 1 { + t.Errorf("expected attempts=1, got %d", attempts) + } +} + +func TestWorker_DeadAfterMaxRetries(t *testing.T) { + repo := setupRepo(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + svc := NewService(repo, "test.local", true) + _ = svc.Enqueue(context.Background(), EventUserAdded, map[string]any{}) + + w := &Worker{ + repo: repo, + url: srv.URL, + timeout: 1 * time.Second, + maxRetries: 2, + hc: &http.Client{Timeout: 1 * time.Second}, + } + // First failure → retry scheduled. + w.tick(context.Background()) + // Force the row eligible again by rewinding next_attempt_at. + if _, err := repo.db.ExecContext(context.Background(), + `UPDATE webhook_outbox SET next_attempt_at = ?`, + time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + w.tick(context.Background()) + + var status string + if err := repo.db.QueryRowContext(context.Background(), + `SELECT status FROM webhook_outbox LIMIT 1`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != string(StatusDead) { + t.Errorf("expected dead after 2 attempts, got %s", status) + } +} + +func TestWorker_NoURL_NoOp(t *testing.T) { + repo := setupRepo(t) + w := NewWorker(repo, "", "", 5, 5) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + w.Run(ctx) // returns when ctx fires; should not hang. +} diff --git a/messages.example.yaml b/messages.example.yaml new file mode 100644 index 0000000..04c7813 --- /dev/null +++ b/messages.example.yaml @@ -0,0 +1,38 @@ +# Available placeholders: +# {username} user's NIP-05 local part +# {npub} user's npub (bech32) +# {pubkey} user's hex pubkey +# {domain} configured domain +# {expires_at} formatted expiry (e.g. "2027-04-27") or "lifetime" +# {days_remaining} integer days until expiry (expiring_soon only) +# {grace_days} configured USERNAME_GRACE_DAYS (expired only) +# {frontend_url} configured FRONTEND_URL +# {subscription_type} "yearly" or "lifetime" +# +# Empty template ("") disables that DM type. + +welcome: | + welcome to nip-05 on {domain} + + you're now {username}@{domain} + expires: {expires_at} + + manage your identity: {frontend_url}?pubkey={npub} + +expiring_soon: | + heads up — your nip-05 {username}@{domain} expires in {days_remaining} days ({expires_at}) + + renew here: {frontend_url}?pubkey={npub} + + yearly or lifetime, your call. + +expired: | + your nip-05 {username}@{domain} has expired and been removed. + + want it back? same username is reserved for you for {grace_days} days: + {frontend_url}?pubkey={npub} + +extended: | + {username}@{domain} renewed + + new expiry: {expires_at}