first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

59
cmd/nip05api/cleanup.go Normal file
View 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
View 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)
}()
}