first commit
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
.data
|
||||
.env
|
||||
bin
|
||||
*.test
|
||||
*.out
|
||||
README.md
|
||||
SPEC.md
|
||||
deploy
|
||||
.golangci.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
@@ -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,_
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.data/
|
||||
.env
|
||||
/messages.yaml
|
||||
bin/
|
||||
*.test
|
||||
*.out
|
||||
.DS_Store
|
||||
.claude/
|
||||
57
.golangci.yml
Normal file
57
.golangci.yml
Normal file
@@ -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
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
41
Makefile
Normal file
41
Makefile
Normal file
@@ -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'."
|
||||
203
README.md
Normal file
203
README.md
Normal file
@@ -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).
|
||||
59
cmd/nip05api/cleanup.go
Normal file
59
cmd/nip05api/cleanup.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
175
cmd/nip05api/main.go
Normal file
175
cmd/nip05api/main.go
Normal file
@@ -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)
|
||||
}()
|
||||
}
|
||||
34
deploy/Caddyfile
Normal file
34
deploy/Caddyfile
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
62
deploy/nginx.conf
Normal file
62
deploy/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
45
deploy/nip05api.service
Normal file
45
deploy/nip05api.service
Normal file
@@ -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
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -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
|
||||
54
go.mod
Normal file
54
go.mod
Normal file
@@ -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
|
||||
)
|
||||
228
go.sum
Normal file
228
go.sum
Normal file
@@ -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=
|
||||
44
internal/audit/audit.go
Normal file
44
internal/audit/audit.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
171
internal/config/config.go
Normal file
171
internal/config/config.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type LightningConfig struct {
|
||||
Enabled bool
|
||||
LNbitsURL string
|
||||
LNbitsInvoiceKey string
|
||||
PriceYearlySats int64
|
||||
PriceLifetimeSats int64
|
||||
InvoiceExpiryMins int
|
||||
}
|
||||
|
||||
type NostrConfig struct {
|
||||
Relays []string
|
||||
UsernameSyncEnabled bool
|
||||
SyncIntervalMins int
|
||||
}
|
||||
|
||||
type DMConfig struct {
|
||||
Enabled bool
|
||||
Nsec string
|
||||
Kind int
|
||||
MessagesFile string
|
||||
}
|
||||
|
||||
type ExpiryConfig struct {
|
||||
ReminderDays []int
|
||||
GraceDays int
|
||||
CronHourUTC int
|
||||
}
|
||||
|
||||
type WebhookConfig struct {
|
||||
URL string
|
||||
Secret string
|
||||
TimeoutSecs int
|
||||
MaxRetries int
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Domain string
|
||||
Port int
|
||||
AdminAPIKey string
|
||||
FrontendURL string
|
||||
DatabasePath string
|
||||
|
||||
Lightning LightningConfig
|
||||
Nostr NostrConfig
|
||||
DM DMConfig
|
||||
Expiry ExpiryConfig
|
||||
Webhook WebhookConfig
|
||||
|
||||
LogLevel string
|
||||
RateLimitPerMin int
|
||||
ReservedUsernames []string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
c := &Config{
|
||||
Domain: env("DOMAIN", ""),
|
||||
Port: envInt("PORT", 8080),
|
||||
AdminAPIKey: env("ADMIN_API_KEY", ""),
|
||||
FrontendURL: env("FRONTEND_URL", ""),
|
||||
DatabasePath: env("DATABASE_PATH", ".data/nip05.db"),
|
||||
Lightning: LightningConfig{
|
||||
Enabled: envBool("LIGHTNING_ENABLED", true),
|
||||
LNbitsURL: env("LNBITS_URL", ""),
|
||||
LNbitsInvoiceKey: env("LNBITS_INVOICE_KEY", ""),
|
||||
PriceYearlySats: int64(envInt("PRICE_YEARLY_SATS", 1000)),
|
||||
PriceLifetimeSats: int64(envInt("PRICE_LIFETIME_SATS", 10000)),
|
||||
InvoiceExpiryMins: envInt("INVOICE_EXPIRY_MINUTES", 30),
|
||||
},
|
||||
Nostr: NostrConfig{
|
||||
Relays: csv(env("RELAYS", "")),
|
||||
UsernameSyncEnabled: envBool("USERNAME_SYNC_ENABLED", true),
|
||||
SyncIntervalMins: envInt("SYNC_INTERVAL_MINUTES", 15),
|
||||
},
|
||||
DM: DMConfig{
|
||||
Enabled: envBool("DM_ENABLED", true),
|
||||
Nsec: env("DM_NSEC", ""),
|
||||
Kind: envInt("DM_KIND", 1059),
|
||||
MessagesFile: env("MESSAGES_FILE", "messages.yaml"),
|
||||
},
|
||||
Expiry: ExpiryConfig{
|
||||
ReminderDays: csvInt(env("EXPIRY_REMINDER_DAYS", "7")),
|
||||
GraceDays: envInt("USERNAME_GRACE_DAYS", 30),
|
||||
CronHourUTC: envInt("EXPIRY_CRON_HOUR_UTC", 9),
|
||||
},
|
||||
Webhook: WebhookConfig{
|
||||
URL: env("WEBHOOK_URL", ""),
|
||||
Secret: env("WEBHOOK_SECRET", ""),
|
||||
TimeoutSecs: envInt("WEBHOOK_TIMEOUT_SECONDS", 10),
|
||||
MaxRetries: envInt("WEBHOOK_MAX_RETRIES", 5),
|
||||
},
|
||||
LogLevel: env("LOG_LEVEL", "info"),
|
||||
RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30),
|
||||
ReservedUsernames: csv(env("RESERVED_USERNAMES", "")),
|
||||
}
|
||||
|
||||
if err := Validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
switch strings.ToLower(v) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func csv(v string) []string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func csvInt(v string) []int {
|
||||
parts := csv(v)
|
||||
out := make([]int, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) }
|
||||
78
internal/config/validate.go
Normal file
78
internal/config/validate.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Validate(c *Config) error {
|
||||
var problems []string
|
||||
if c.Domain == "" {
|
||||
problems = append(problems, "DOMAIN is required")
|
||||
}
|
||||
if c.Port <= 0 || c.Port > 65535 {
|
||||
problems = append(problems, "PORT must be 1-65535")
|
||||
}
|
||||
if c.AdminAPIKey == "" || c.AdminAPIKey == "change-me-to-a-long-random-string" {
|
||||
problems = append(problems, "ADMIN_API_KEY must be set to a non-default value")
|
||||
} else if len(c.AdminAPIKey) < 24 {
|
||||
problems = append(problems, "ADMIN_API_KEY must be at least 24 characters")
|
||||
}
|
||||
if c.DatabasePath == "" {
|
||||
problems = append(problems, "DATABASE_PATH is required")
|
||||
}
|
||||
if c.Lightning.Enabled {
|
||||
if c.Lightning.LNbitsURL == "" {
|
||||
problems = append(problems, "LNBITS_URL is required when LIGHTNING_ENABLED=true")
|
||||
} else if !strings.HasPrefix(c.Lightning.LNbitsURL, "http://") && !strings.HasPrefix(c.Lightning.LNbitsURL, "https://") {
|
||||
problems = append(problems, "LNBITS_URL must start with http:// or https://")
|
||||
}
|
||||
if c.Lightning.LNbitsInvoiceKey == "" {
|
||||
problems = append(problems, "LNBITS_INVOICE_KEY is required when LIGHTNING_ENABLED=true")
|
||||
}
|
||||
if c.Lightning.PriceYearlySats <= 0 {
|
||||
problems = append(problems, "PRICE_YEARLY_SATS must be > 0")
|
||||
}
|
||||
if c.Lightning.PriceLifetimeSats <= 0 {
|
||||
problems = append(problems, "PRICE_LIFETIME_SATS must be > 0")
|
||||
}
|
||||
if c.Lightning.InvoiceExpiryMins <= 0 {
|
||||
problems = append(problems, "INVOICE_EXPIRY_MINUTES must be > 0")
|
||||
}
|
||||
}
|
||||
if c.DM.Enabled {
|
||||
if c.DM.Nsec == "" {
|
||||
problems = append(problems, "DM_NSEC is required when DM_ENABLED=true")
|
||||
}
|
||||
if c.DM.Kind != 4 && c.DM.Kind != 1059 {
|
||||
problems = append(problems, "DM_KIND must be 4 or 1059")
|
||||
}
|
||||
}
|
||||
if (c.Nostr.UsernameSyncEnabled || c.DM.Enabled) && len(c.Nostr.Relays) == 0 {
|
||||
problems = append(problems, "RELAYS is required when sync or DM is enabled")
|
||||
}
|
||||
for _, r := range c.Nostr.Relays {
|
||||
if !strings.HasPrefix(r, "ws://") && !strings.HasPrefix(r, "wss://") {
|
||||
problems = append(problems, "RELAYS entry must be ws:// or wss://, got "+r)
|
||||
}
|
||||
}
|
||||
if c.Webhook.URL != "" {
|
||||
if !strings.HasPrefix(c.Webhook.URL, "http://") && !strings.HasPrefix(c.Webhook.URL, "https://") {
|
||||
problems = append(problems, "WEBHOOK_URL must start with http:// or https://")
|
||||
}
|
||||
}
|
||||
if c.Expiry.GraceDays < 0 {
|
||||
problems = append(problems, "USERNAME_GRACE_DAYS must be >= 0")
|
||||
}
|
||||
if c.Expiry.CronHourUTC < 0 || c.Expiry.CronHourUTC > 23 {
|
||||
problems = append(problems, "EXPIRY_CRON_HOUR_UTC must be 0-23")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("invalid config:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var ErrInvalidConfig = errors.New("invalid config")
|
||||
32
internal/config/validate_test.go
Normal file
32
internal/config/validate_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidate_Defaults(t *testing.T) {
|
||||
c := &Config{
|
||||
Domain: "azzamo.net",
|
||||
Port: 8080,
|
||||
AdminAPIKey: "long-secret-key-for-tests",
|
||||
DatabasePath: ".data/nip05.db",
|
||||
}
|
||||
if err := Validate(c); err != nil {
|
||||
t.Fatalf("expected ok, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_MissingDomain(t *testing.T) {
|
||||
c := &Config{Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db"}
|
||||
if err := Validate(c); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_LightningRequiresLNbits(t *testing.T) {
|
||||
c := &Config{
|
||||
Domain: "azzamo.net", Port: 8080, AdminAPIKey: "long", DatabasePath: ".data/nip05.db",
|
||||
Lightning: LightningConfig{Enabled: true},
|
||||
}
|
||||
if err := Validate(c); err == nil {
|
||||
t.Fatal("expected error for missing LNBITS_URL")
|
||||
}
|
||||
}
|
||||
38
internal/db/db.go
Normal file
38
internal/db/db.go
Normal file
@@ -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() }
|
||||
32
internal/db/db_test.go
Normal file
32
internal/db/db_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
114
internal/db/migrate.go
Normal file
114
internal/db/migrate.go
Normal file
@@ -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
|
||||
}
|
||||
73
internal/db/migrations/0001_init.sql
Normal file
73
internal/db/migrations/0001_init.sql
Normal file
@@ -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
|
||||
);
|
||||
9
internal/db/migrations/0002_idempotent_target.sql
Normal file
9
internal/db/migrations/0002_idempotent_target.sql
Normal file
@@ -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);
|
||||
87
internal/dm/encrypt.go
Normal file
87
internal/dm/encrypt.go
Normal file
@@ -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
|
||||
}
|
||||
33
internal/dm/model.go
Normal file
33
internal/dm/model.go
Normal file
@@ -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
|
||||
}
|
||||
86
internal/dm/repo.go
Normal file
86
internal/dm/repo.go
Normal file
@@ -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
|
||||
}
|
||||
35
internal/dm/service.go
Normal file
35
internal/dm/service.go
Normal file
@@ -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)
|
||||
}
|
||||
109
internal/dm/worker.go
Normal file
109
internal/dm/worker.go
Normal file
@@ -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)
|
||||
}
|
||||
175
internal/expiry/worker.go
Normal file
175
internal/expiry/worker.go
Normal file
@@ -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
|
||||
}
|
||||
204
internal/expiry/worker_test.go
Normal file
204
internal/expiry/worker_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
93
internal/http/docs/docs.go
Normal file
93
internal/http/docs/docs.go
Normal file
@@ -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 = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>NIP-05 API</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func ServeUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(swaggerHTML))
|
||||
}
|
||||
297
internal/http/docs/openapi.yaml
Normal file
297
internal/http/docs/openapi.yaml
Normal file
@@ -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 }
|
||||
80
internal/http/handlers/admin_extend.go
Normal file
80
internal/http/handlers/admin_extend.go
Normal file
@@ -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))
|
||||
}
|
||||
57
internal/http/handlers/admin_helpers.go
Normal file
57
internal/http/handlers/admin_helpers.go
Normal file
@@ -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
|
||||
}
|
||||
166
internal/http/handlers/admin_users.go
Normal file
166
internal/http/handlers/admin_users.go
Normal file
@@ -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})
|
||||
}
|
||||
|
||||
26
internal/http/handlers/health.go
Normal file
26
internal/http/handlers/health.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
109
internal/http/handlers/invoices.go
Normal file
109
internal/http/handlers/invoices.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
41
internal/http/handlers/nostrjson.go
Normal file
41
internal/http/handlers/nostrjson.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
17
internal/http/handlers/pricing.go
Normal file
17
internal/http/handlers/pricing.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
19
internal/http/handlers/respond.go
Normal file
19
internal/http/handlers/respond.go
Normal file
@@ -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})
|
||||
}
|
||||
40
internal/http/handlers/usernames.go
Normal file
40
internal/http/handlers/usernames.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
63
internal/http/handlers/users.go
Normal file
63
internal/http/handlers/users.go
Normal file
@@ -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)
|
||||
}
|
||||
25
internal/http/middleware/adminauth.go
Normal file
25
internal/http/middleware/adminauth.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
19
internal/http/middleware/bodylimit.go
Normal file
19
internal/http/middleware/bodylimit.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
18
internal/http/middleware/cors.go
Normal file
18
internal/http/middleware/cors.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
49
internal/http/middleware/logging.go
Normal file
49
internal/http/middleware/logging.go
Normal file
@@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
27
internal/http/middleware/ratelimit.go
Normal file
27
internal/http/middleware/ratelimit.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
34
internal/http/middleware/realip.go
Normal file
34
internal/http/middleware/realip.go
Normal file
@@ -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 ""
|
||||
}
|
||||
42
internal/http/middleware/recoverer.go
Normal file
42
internal/http/middleware/recoverer.go
Normal file
@@ -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
|
||||
}
|
||||
99
internal/http/server.go
Normal file
99
internal/http/server.go
Normal file
@@ -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)
|
||||
}
|
||||
357
internal/http/server_test.go
Normal file
357
internal/http/server_test.go
Normal file
@@ -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
|
||||
}
|
||||
104
internal/invoice/lnbits.go
Normal file
104
internal/invoice/lnbits.go
Normal file
@@ -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
|
||||
}
|
||||
52
internal/invoice/model.go
Normal file
52
internal/invoice/model.go
Normal file
@@ -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
|
||||
}
|
||||
163
internal/invoice/repo.go
Normal file
163
internal/invoice/repo.go
Normal file
@@ -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
|
||||
}
|
||||
186
internal/invoice/service.go
Normal file
186
internal/invoice/service.go
Normal file
@@ -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")
|
||||
}
|
||||
227
internal/invoice/service_test.go
Normal file
227
internal/invoice/service_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
48
internal/log/log.go
Normal file
48
internal/log/log.go
Normal file
@@ -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()
|
||||
}
|
||||
36
internal/messages/defaults.go
Normal file
36
internal/messages/defaults.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
52
internal/messages/messages.go
Normal file
52
internal/messages/messages.go
Normal file
@@ -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
|
||||
}
|
||||
18
internal/messages/render.go
Normal file
18
internal/messages/render.go
Normal file
@@ -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
|
||||
}
|
||||
62
internal/messages/render_test.go
Normal file
62
internal/messages/render_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
72
internal/nostr/keys.go
Normal file
72
internal/nostr/keys.go
Normal file
@@ -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
|
||||
}
|
||||
41
internal/nostr/keys_test.go
Normal file
41
internal/nostr/keys_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
81
internal/nostr/profile.go
Normal file
81
internal/nostr/profile.go
Normal file
@@ -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)
|
||||
}
|
||||
59
internal/nostr/profile_test.go
Normal file
59
internal/nostr/profile_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
36
internal/nostr/publish.go
Normal file
36
internal/nostr/publish.go
Normal file
@@ -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
|
||||
}
|
||||
51
internal/nostr/relay.go
Normal file
51
internal/nostr/relay.go
Normal file
@@ -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{}
|
||||
}
|
||||
69
internal/payments/dispatch.go
Normal file
69
internal/payments/dispatch.go
Normal file
@@ -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),
|
||||
}
|
||||
}
|
||||
173
internal/payments/worker.go
Normal file
173
internal/payments/worker.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
180
internal/payments/worker_test.go
Normal file
180
internal/payments/worker_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
139
internal/sync/worker.go
Normal file
139
internal/sync/worker.go
Normal file
@@ -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
|
||||
}
|
||||
74
internal/user/model.go
Normal file
74
internal/user/model.go
Normal file
@@ -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_<first 16 hex>` 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
|
||||
}
|
||||
55
internal/user/model_test.go
Normal file
55
internal/user/model_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
101
internal/user/nostr_sync.go
Normal file
101
internal/user/nostr_sync.go
Normal file
@@ -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 ""
|
||||
}
|
||||
119
internal/user/nostr_sync_test.go
Normal file
119
internal/user/nostr_sync_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
internal/user/repo.go
Normal file
150
internal/user/repo.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
|
||||
115
internal/user/repo_query.go
Normal file
115
internal/user/repo_query.go
Normal file
@@ -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)
|
||||
}
|
||||
56
internal/user/repo_test.go
Normal file
56
internal/user/repo_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
126
internal/user/service.go
Normal file
126
internal/user/service.go
Normal file
@@ -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
|
||||
}
|
||||
46
internal/user/service_test.go
Normal file
46
internal/user/service_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
39
internal/webhook/model.go
Normal file
39
internal/webhook/model.go
Normal file
@@ -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
|
||||
}
|
||||
86
internal/webhook/repo.go
Normal file
86
internal/webhook/repo.go
Normal file
@@ -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
|
||||
}
|
||||
36
internal/webhook/service.go
Normal file
36
internal/webhook/service.go
Normal file
@@ -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))
|
||||
}
|
||||
16
internal/webhook/signer.go
Normal file
16
internal/webhook/signer.go
Normal file
@@ -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))
|
||||
}
|
||||
21
internal/webhook/signer_test.go
Normal file
21
internal/webhook/signer_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
123
internal/webhook/worker.go
Normal file
123
internal/webhook/worker.go
Normal file
@@ -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)
|
||||
}
|
||||
179
internal/webhook/worker_test.go
Normal file
179
internal/webhook/worker_test.go
Normal file
@@ -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.
|
||||
}
|
||||
38
messages.example.yaml
Normal file
38
messages.example.yaml
Normal file
@@ -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}
|
||||
Reference in New Issue
Block a user