From 2cb17df4c535767d6cbef1e79fe66dba6799a98c Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 29 Apr 2026 02:35:00 +0000 Subject: [PATCH] first commit --- .dockerignore | 14 + .env.example | 43 +++ .gitignore | 8 + .golangci.yml | 57 +++ Dockerfile | 25 ++ LICENSE | 21 ++ Makefile | 41 ++ README.md | 203 ++++++++++ cmd/nip05api/cleanup.go | 59 +++ cmd/nip05api/main.go | 175 +++++++++ deploy/Caddyfile | 34 ++ deploy/nginx.conf | 62 +++ deploy/nip05api.service | 45 +++ docker-compose.yml | 25 ++ go.mod | 54 +++ go.sum | 228 +++++++++++ internal/audit/audit.go | 44 +++ internal/config/config.go | 171 +++++++++ internal/config/validate.go | 78 ++++ internal/config/validate_test.go | 32 ++ internal/db/db.go | 38 ++ internal/db/db_test.go | 32 ++ internal/db/migrate.go | 114 ++++++ internal/db/migrations/0001_init.sql | 73 ++++ .../db/migrations/0002_idempotent_target.sql | 9 + internal/dm/encrypt.go | 87 +++++ internal/dm/model.go | 33 ++ internal/dm/repo.go | 86 +++++ internal/dm/service.go | 35 ++ internal/dm/worker.go | 109 ++++++ internal/expiry/worker.go | 175 +++++++++ internal/expiry/worker_test.go | 204 ++++++++++ internal/http/docs/docs.go | 93 +++++ internal/http/docs/openapi.yaml | 297 +++++++++++++++ internal/http/handlers/admin_extend.go | 80 ++++ internal/http/handlers/admin_helpers.go | 57 +++ internal/http/handlers/admin_users.go | 166 ++++++++ internal/http/handlers/health.go | 26 ++ internal/http/handlers/invoices.go | 109 ++++++ internal/http/handlers/nostrjson.go | 41 ++ internal/http/handlers/pricing.go | 17 + internal/http/handlers/respond.go | 19 + internal/http/handlers/usernames.go | 40 ++ internal/http/handlers/users.go | 63 ++++ internal/http/middleware/adminauth.go | 25 ++ internal/http/middleware/bodylimit.go | 19 + internal/http/middleware/cors.go | 18 + internal/http/middleware/logging.go | 49 +++ internal/http/middleware/ratelimit.go | 27 ++ internal/http/middleware/realip.go | 34 ++ internal/http/middleware/recoverer.go | 42 +++ internal/http/server.go | 99 +++++ internal/http/server_test.go | 357 ++++++++++++++++++ internal/invoice/lnbits.go | 104 +++++ internal/invoice/model.go | 52 +++ internal/invoice/repo.go | 163 ++++++++ internal/invoice/service.go | 186 +++++++++ internal/invoice/service_test.go | 227 +++++++++++ internal/log/log.go | 48 +++ internal/messages/defaults.go | 36 ++ internal/messages/messages.go | 52 +++ internal/messages/render.go | 18 + internal/messages/render_test.go | 62 +++ internal/nostr/keys.go | 72 ++++ internal/nostr/keys_test.go | 41 ++ internal/nostr/profile.go | 81 ++++ internal/nostr/profile_test.go | 59 +++ internal/nostr/publish.go | 36 ++ internal/nostr/relay.go | 51 +++ internal/payments/dispatch.go | 69 ++++ internal/payments/worker.go | 173 +++++++++ internal/payments/worker_test.go | 180 +++++++++ internal/sync/worker.go | 139 +++++++ internal/user/model.go | 74 ++++ internal/user/model_test.go | 55 +++ internal/user/nostr_sync.go | 101 +++++ internal/user/nostr_sync_test.go | 119 ++++++ internal/user/repo.go | 150 ++++++++ internal/user/repo_query.go | 115 ++++++ internal/user/repo_test.go | 56 +++ internal/user/service.go | 126 +++++++ internal/user/service_test.go | 46 +++ internal/webhook/model.go | 39 ++ internal/webhook/repo.go | 86 +++++ internal/webhook/service.go | 36 ++ internal/webhook/signer.go | 16 + internal/webhook/signer_test.go | 21 ++ internal/webhook/worker.go | 123 ++++++ internal/webhook/worker_test.go | 179 +++++++++ messages.example.yaml | 38 ++ 90 files changed, 7321 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/nip05api/cleanup.go create mode 100644 cmd/nip05api/main.go create mode 100644 deploy/Caddyfile create mode 100644 deploy/nginx.conf create mode 100644 deploy/nip05api.service create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/audit/audit.go create mode 100644 internal/config/config.go create mode 100644 internal/config/validate.go create mode 100644 internal/config/validate_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/migrate.go create mode 100644 internal/db/migrations/0001_init.sql create mode 100644 internal/db/migrations/0002_idempotent_target.sql create mode 100644 internal/dm/encrypt.go create mode 100644 internal/dm/model.go create mode 100644 internal/dm/repo.go create mode 100644 internal/dm/service.go create mode 100644 internal/dm/worker.go create mode 100644 internal/expiry/worker.go create mode 100644 internal/expiry/worker_test.go create mode 100644 internal/http/docs/docs.go create mode 100644 internal/http/docs/openapi.yaml create mode 100644 internal/http/handlers/admin_extend.go create mode 100644 internal/http/handlers/admin_helpers.go create mode 100644 internal/http/handlers/admin_users.go create mode 100644 internal/http/handlers/health.go create mode 100644 internal/http/handlers/invoices.go create mode 100644 internal/http/handlers/nostrjson.go create mode 100644 internal/http/handlers/pricing.go create mode 100644 internal/http/handlers/respond.go create mode 100644 internal/http/handlers/usernames.go create mode 100644 internal/http/handlers/users.go create mode 100644 internal/http/middleware/adminauth.go create mode 100644 internal/http/middleware/bodylimit.go create mode 100644 internal/http/middleware/cors.go create mode 100644 internal/http/middleware/logging.go create mode 100644 internal/http/middleware/ratelimit.go create mode 100644 internal/http/middleware/realip.go create mode 100644 internal/http/middleware/recoverer.go create mode 100644 internal/http/server.go create mode 100644 internal/http/server_test.go create mode 100644 internal/invoice/lnbits.go create mode 100644 internal/invoice/model.go create mode 100644 internal/invoice/repo.go create mode 100644 internal/invoice/service.go create mode 100644 internal/invoice/service_test.go create mode 100644 internal/log/log.go create mode 100644 internal/messages/defaults.go create mode 100644 internal/messages/messages.go create mode 100644 internal/messages/render.go create mode 100644 internal/messages/render_test.go create mode 100644 internal/nostr/keys.go create mode 100644 internal/nostr/keys_test.go create mode 100644 internal/nostr/profile.go create mode 100644 internal/nostr/profile_test.go create mode 100644 internal/nostr/publish.go create mode 100644 internal/nostr/relay.go create mode 100644 internal/payments/dispatch.go create mode 100644 internal/payments/worker.go create mode 100644 internal/payments/worker_test.go create mode 100644 internal/sync/worker.go create mode 100644 internal/user/model.go create mode 100644 internal/user/model_test.go create mode 100644 internal/user/nostr_sync.go create mode 100644 internal/user/nostr_sync_test.go create mode 100644 internal/user/repo.go create mode 100644 internal/user/repo_query.go create mode 100644 internal/user/repo_test.go create mode 100644 internal/user/service.go create mode 100644 internal/user/service_test.go create mode 100644 internal/webhook/model.go create mode 100644 internal/webhook/repo.go create mode 100644 internal/webhook/service.go create mode 100644 internal/webhook/signer.go create mode 100644 internal/webhook/signer_test.go create mode 100644 internal/webhook/worker.go create mode 100644 internal/webhook/worker_test.go create mode 100644 messages.example.yaml 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}