Compare commits

...

4 Commits

Author SHA1 Message Date
5dcd671043 Support comma-separated CORS_HEADER for multiple origins.
Parse CORS_HEADER as a list: * for all origins, or reflect matching
request Origin when multiple specific origins are configured. Add Vary:
Origin for the allowlist case. Update .env.example and CORS tests.
2026-05-06 20:38:28 +00:00
43d78862e3 Add configurable LNbits invoice memos and pubkey metadata
Read INVOICE_MEMO_YEARLY and INVOICE_MEMO_LIFETIME from the environment
and pass the user pubkey in LNbits payment extra for invoice creation.
2026-05-06 19:52:07 +00:00
fe2b95258d feat: admin endpoints to reset username sync flags
Add POST /v1/admin/users/{pubkey}/reset-username and
POST /v1/admin/users/reset-usernames to clear manual_username
and last_synced_at so nostr profile sync re-evaluates users.
Includes OpenAPI docs, audit actions, and tests.
2026-05-06 19:31:13 +00:00
c6bdb7f825 fix: allow profile sync for admin-created users
Stop pinning usernames on admin Add (manual_username=false) so sync matches
invoice-created users. Add migration to reset manual_username for existing
rows so they re-enter ListForSync; explicit renames still pin via SetUsername.
2026-05-06 19:22:34 +00:00
14 changed files with 389 additions and 97 deletions

View File

@@ -4,11 +4,10 @@ PORT=8080
ADMIN_API_KEY=change-me-to-a-long-random-string
FRONTEND_URL=https://azzamo.net/nip05
# Optional extra browser origins (comma-separated). Merged with FRONTEND_URL for CORS.
# CORS_ORIGINS=
# Allow http(s)://localhost:* and 127.0.0.1 for local UI dev hitting this API directly (not via Vite proxy).
CORS_ALLOW_LOCALHOST=true
# --- CORS ---
# Comma-separated list of allowed origins, or "*" to allow all.
# Examples: "*" | "https://azzamo.net" | "https://azzamo.net,https://other.example"
CORS_HEADER=*
# --- Database ---
DATABASE_PATH=.data/nip05.db
@@ -20,6 +19,8 @@ LNBITS_INVOICE_KEY=your-lnbits-invoice-read-key
PRICE_YEARLY_SATS=1000
PRICE_LIFETIME_SATS=10000
INVOICE_EXPIRY_MINUTES=30
INVOICE_MEMO_YEARLY=Noderunners Relay yearly Access
INVOICE_MEMO_LIFETIME=Noderunners Relay lifetime Access
# --- Nostr ---
RELAYS=wss://relay.azzamo.net,wss://nostr.azzamo.net,wss://wot.azzamo.net

View File

@@ -102,6 +102,8 @@ func run() error {
YearlySats: cfg.Lightning.PriceYearlySats,
LifetimeSats: cfg.Lightning.PriceLifetimeSats,
ExpiryMins: cfg.Lightning.InvoiceExpiryMins,
MemoYearly: cfg.Lightning.InvoiceMemoYearly,
MemoLifetime: cfg.Lightning.InvoiceMemoLifetime,
}, cfg.Domain)
}

View File

@@ -14,6 +14,8 @@ const (
ActionUserAdded = "user.added"
ActionUserUsernameChanged = "user.username_changed"
ActionUserUsernameReset = "user.username_reset"
ActionAllUsernamesReset = "users.usernames_reset"
ActionUserExtended = "user.extended"
ActionUserDeleted = "user.deleted"
ActionPaymentConfirmed = "payment.confirmed"

View File

@@ -10,12 +10,14 @@ import (
)
type LightningConfig struct {
Enabled bool
LNbitsURL string
LNbitsInvoiceKey string
PriceYearlySats int64
PriceLifetimeSats int64
InvoiceExpiryMins int
Enabled bool
LNbitsURL string
LNbitsInvoiceKey string
PriceYearlySats int64
PriceLifetimeSats int64
InvoiceExpiryMins int
InvoiceMemoYearly string
InvoiceMemoLifetime string
}
type NostrConfig struct {
@@ -61,10 +63,10 @@ type Config struct {
RateLimitPerMin int
ReservedUsernames []string
// CORS: exact origin list = FRONTEND_URL CORS_ORIGINS; loopback hosts if CORS_ALLOW_LOCALHOST.
CORSExtraOrigins []string
CORSAllowLocalhost bool
CORSAllowCredentials bool
// CORSOrigins is parsed from the CORS_HEADER env var (comma-separated).
// Use "*" to allow all origins, or list specific origins like
// "https://example.com,https://other.example".
CORSOrigins []string
}
func Load() (*Config, error) {
@@ -77,12 +79,14 @@ func Load() (*Config, error) {
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),
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),
InvoiceMemoYearly: env("INVOICE_MEMO_YEARLY", "Noderunners Relay yearly Access"),
InvoiceMemoLifetime: env("INVOICE_MEMO_LIFETIME", "Noderunners Relay lifetime Access"),
},
Nostr: NostrConfig{
Relays: csv(env("RELAYS", "")),
@@ -109,9 +113,7 @@ func Load() (*Config, error) {
LogLevel: env("LOG_LEVEL", "info"),
RateLimitPerMin: envInt("RATE_LIMIT_PER_MIN", 30),
ReservedUsernames: csv(env("RESERVED_USERNAMES", "")),
CORSExtraOrigins: csv(env("CORS_ORIGINS", "")),
CORSAllowLocalhost: envBool("CORS_ALLOW_LOCALHOST", true),
CORSAllowCredentials: envBool("CORS_ALLOW_CREDENTIALS", false),
CORSOrigins: csv(env("CORS_HEADER", "*")),
}
if err := Validate(c); err != nil {
@@ -177,22 +179,3 @@ func csvInt(v string) []int {
}
func (c *Config) Addr() string { return fmt.Sprintf(":%d", c.Port) }
// CORSExactOrigins lists allowed browser Origins for exact match (before loopback wildcard).
func (c *Config) CORSExactOrigins() []string {
seen := make(map[string]bool)
out := make([]string, 0, 4+len(c.CORSExtraOrigins))
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(c.FrontendURL)
for _, o := range c.CORSExtraOrigins {
add(o)
}
return out
}

View File

@@ -0,0 +1,7 @@
-- Reset manual_username for all users so the profile sync worker can
-- re-evaluate every active user. Earlier admin Add calls always pinned the
-- username at creation, which permanently excluded those rows from
-- ListForSync. Going forward, only explicit username changes (admin Update
-- via SetUsername) pin the row; sync will be a no-op when the kind:0
-- profile already matches the stored handle.
UPDATE users SET manual_username = 0 WHERE manual_username = 1;

View File

@@ -290,6 +290,48 @@ paths:
'200': { description: Deleted }
'401': { description: Unauthorized }
'404': { description: Not found }
/v1/admin/users/reset-usernames:
post:
tags: [Admin]
summary: Reset all usernames (admin)
description: |
Clears `manual_username` and `last_synced_at` for every active user so
the nostr profile sync worker re-evaluates each one on its next cycle.
The stored username is left untouched until the worker overwrites it
with fresh kind:0 metadata. Idempotent.
security: [{ AdminAPIKey: [] }]
responses:
'200':
description: Reset count
content:
application/json:
schema:
type: object
properties:
reset: { type: integer, description: "Number of users whose sync flags were cleared" }
'401': { description: Unauthorized }
/v1/admin/users/{pubkey}/reset-username:
post:
tags: [Admin]
summary: Reset username for a user (admin)
description: |
Clears `manual_username` and `last_synced_at` for the given user so the
nostr profile sync worker re-evaluates their kind:0 metadata on its next
cycle. The stored username is left untouched until the worker overwrites
it. Idempotent.
security: [{ AdminAPIKey: [] }]
parameters:
- in: path
name: pubkey
required: true
schema: { type: string }
responses:
'200':
description: Reset
content: { application/json: { schema: { $ref: '#/components/schemas/User' } } }
'400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
'401': { description: Unauthorized }
'404': { description: Not found }
/v1/admin/users/{pubkey}/extend:
post:
tags: [Admin]

View File

@@ -66,7 +66,7 @@ func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) {
}
}
u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, true)
u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, false)
if err != nil {
if errors.Is(err, user.ErrInvalidUsername) {
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username")
@@ -144,6 +144,39 @@ func (h *AdminUsers) Update(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, userResponse(u))
}
func (h *AdminUsers) ResetUsername(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.ResetUsername(r.Context(), hexpk)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
return
}
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
h.Audit.Log(r.Context(), audit.ActionUserUsernameReset, audit.ActorAdmin, u.Pubkey, map[string]any{
"username": u.Username,
})
WriteJSON(w, http.StatusOK, userResponse(u))
}
func (h *AdminUsers) ResetAllUsernames(w http.ResponseWriter, r *http.Request) {
count, err := h.Users.ResetAllUsernames(r.Context())
if err != nil {
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
return
}
h.Audit.Log(r.Context(), audit.ActionAllUsernamesReset, audit.ActorAdmin, "", map[string]any{
"reset": count,
})
WriteJSON(w, http.StatusOK, map[string]any{"reset": count})
}
func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) {
hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey"))
if err != nil {

View File

@@ -2,27 +2,44 @@ package middleware
import (
"net/http"
"net/url"
"strings"
"github.com/noderunners/nip05api/internal/config"
)
// CORS sends at most one Access-Control-Allow-Origin value (echo of request Origin).
// Configure FRONTEND_URL, optional CORS_ORIGINS, and CORS_ALLOW_LOCALHOST / CORS_ALLOW_CREDENTIALS.
// CORS sets Access-Control-Allow-Origin based on the CORS_HEADER env var.
//
// Supports "*" (allow all), a single origin, or a comma-separated list.
// When multiple origins are configured the middleware reflects the request
// Origin back if it matches one of the allowed values (the HTTP spec forbids
// sending more than one origin in the header).
func CORS(cfg *config.Config) func(http.Handler) http.Handler {
allowAll := len(cfg.CORSOrigins) == 1 && cfg.CORSOrigins[0] == "*"
allowed := make(map[string]bool, len(cfg.CORSOrigins))
for _, o := range cfg.CORSOrigins {
allowed[o] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
var origin string
if allowAll {
origin = "*"
} else {
reqOrigin := r.Header.Get("Origin")
if allowed[reqOrigin] {
origin = reqOrigin
}
}
if origin != "" && originAllowed(origin, cfg) {
if origin != "" {
h := w.Header()
h.Set("Access-Control-Allow-Origin", 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 cfg.CORSAllowCredentials {
h.Set("Access-Control-Allow-Credentials", "true")
if !allowAll {
h.Set("Vary", "Origin")
}
}
@@ -35,36 +52,3 @@ func CORS(cfg *config.Config) func(http.Handler) http.Handler {
})
}
}
func originAllowed(origin string, cfg *config.Config) bool {
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
for _, allowed := range cfg.CORSExactOrigins() {
if origin == allowed {
return true
}
}
if cfg.CORSAllowLocalhost && isLoopbackOrigin(u) {
return true
}
return false
}
func isLoopbackOrigin(u *url.URL) bool {
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
switch host {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}

View File

@@ -79,9 +79,11 @@ func NewServer(d Deps) *http.Server {
r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey))
r.Post("/users", adminUsers.Add)
r.Get("/users", adminUsers.List)
r.Post("/users/reset-usernames", adminUsers.ResetAllUsernames)
r.Put("/users/{pubkey}", adminUsers.Update)
r.Delete("/users/{pubkey}", adminUsers.Delete)
r.Post("/users/{pubkey}/extend", adminExtend.Handle)
r.Post("/users/{pubkey}/reset-username", adminUsers.ResetUsername)
})
})

View File

@@ -3,6 +3,7 @@ package http_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -47,6 +48,7 @@ func newFixture(t *testing.T) *fixture {
Expiry: config.ExpiryConfig{GraceDays: 30},
ReservedUsernames: []string{"admin", "root"},
RateLimitPerMin: 0, // disabled in tests
CORSOrigins: []string{"*"},
}
tmpls, _ := messages.Load("/nonexistent.yaml")
users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames)
@@ -353,6 +355,179 @@ func TestAdminAdd_BadInputs(t *testing.T) {
}
}
func TestAdminResetUsername(t *testing.T) {
f := newFixture(t)
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)
}
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)
}
var updated map[string]any
_ = json.Unmarshal(body, &updated)
if updated["manual_username"] != true {
t.Fatalf("expected manual_username=true after update, got %v", updated["manual_username"])
}
if _, err := f.db.ExecContext(context.Background(),
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
"2099-01-01T00:00:00Z", testHex); err != nil {
t.Fatal(err)
}
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 200 {
t.Fatalf("reset status %d: %s", resp.StatusCode, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["manual_username"] != false {
t.Errorf("expected manual_username=false after reset, got %v (body %s)", got["manual_username"], body)
}
if got["username"] != "alice2" {
t.Errorf("expected username unchanged after reset, got %v", got["username"])
}
var lastSynced sql.NullString
if err := f.db.QueryRow(`SELECT last_synced_at FROM users WHERE pubkey = ?`, testHex).
Scan(&lastSynced); err != nil {
t.Fatal(err)
}
if lastSynced.Valid && lastSynced.String != "" {
t.Errorf("expected last_synced_at to be NULL after reset, got %q", lastSynced.String)
}
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 200 {
t.Fatalf("idempotent reset status %d: %s", resp.StatusCode, body)
}
if resp, _ := f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil); resp.StatusCode != 200 {
t.Fatalf("delete status %d", resp.StatusCode)
}
resp, _ = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/reset-username", nil)
if resp.StatusCode != 404 {
t.Errorf("expected 404 for unknown pubkey, got %d", resp.StatusCode)
}
resp, _ = f.admin(t, "POST", "/v1/admin/users/notapubkey/reset-username", nil)
if resp.StatusCode != 400 {
t.Errorf("expected 400 for invalid pubkey, got %d", resp.StatusCode)
}
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
"user.username_reset")
var n int
if err := row.Scan(&n); err != nil {
t.Fatal(err)
}
if n < 1 {
t.Errorf("expected user.username_reset audit entry, got %d", n)
}
}
func TestAdminResetAllUsernames(t *testing.T) {
f := newFixture(t)
// Two more 64-char hex strings — not necessarily valid Schnorr keys, so we
// insert them via raw SQL to bypass the admin endpoint validation.
hexB := "1f8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1d"
hexInactive := "2a8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1e"
if resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{
"pubkey": testHex, "username": "alice",
"subscription_type": "yearly", "years": 1,
}); resp.StatusCode != 201 {
t.Fatalf("add A status %d: %s", resp.StatusCode, body)
}
if resp, body := f.admin(t, "PUT", "/v1/admin/users/"+testHex,
map[string]any{"username": "alice2"}); resp.StatusCode != 200 {
t.Fatalf("pin A status %d: %s", resp.StatusCode, body)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
VALUES (?, 'bob', 'yearly', NULL, 1, 1, ?, datetime('now'))`,
hexB, "2099-01-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
if _, err := f.db.ExecContext(context.Background(),
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, created_at)
VALUES (?, 'carol', 'yearly', NULL, 0, 1, ?, datetime('now'))`,
hexInactive, "2099-01-01T00:00:00Z"); err != nil {
t.Fatal(err)
}
if _, err := f.db.ExecContext(context.Background(),
`UPDATE users SET last_synced_at = ? WHERE pubkey = ?`,
"2099-01-01T00:00:00Z", testHex); err != nil {
t.Fatal(err)
}
resp, body := f.admin(t, "POST", "/v1/admin/users/reset-usernames", nil)
if resp.StatusCode != 200 {
t.Fatalf("bulk reset status %d: %s", resp.StatusCode, body)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if cnt, _ := got["reset"].(float64); cnt != 2 {
t.Errorf("expected reset=2 (active users only), got %v (body %s)", got["reset"], body)
}
rows, err := f.db.Query(`SELECT pubkey, manual_username, last_synced_at FROM users`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var pk string
var manual int
var lastSynced sql.NullString
if err := rows.Scan(&pk, &manual, &lastSynced); err != nil {
t.Fatal(err)
}
switch pk {
case testHex, hexB:
if manual != 0 {
t.Errorf("%s: expected manual_username=0 after reset, got %d", pk, manual)
}
if lastSynced.Valid && lastSynced.String != "" {
t.Errorf("%s: expected last_synced_at NULL after reset, got %q", pk, lastSynced.String)
}
case hexInactive:
if manual != 1 {
t.Errorf("inactive user manual_username should be untouched, got %d", manual)
}
if !lastSynced.Valid || lastSynced.String == "" {
t.Errorf("inactive user last_synced_at should be untouched, got %v", lastSynced)
}
}
}
row := f.db.QueryRow(`SELECT COUNT(*) FROM audit_log WHERE action = ?`,
"users.usernames_reset")
var n int
if err := row.Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("expected 1 users.usernames_reset audit entry, got %d", n)
}
}
func TestOpenAPI(t *testing.T) {
f := newFixture(t)
resp, body := f.get(t, "/openapi.json")
@@ -379,6 +554,29 @@ func TestDocsPage(t *testing.T) {
}
}
func TestCORSHeader(t *testing.T) {
f := newFixture(t)
resp, _ := f.get(t, "/healthz")
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("expected Access-Control-Allow-Origin=*, got %q", got)
}
req, _ := http.NewRequest("OPTIONS", f.srv.URL+"/v1/pricing", nil)
req.Header.Set("Origin", "https://random-frontend.example")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 204 on OPTIONS preflight, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("expected Access-Control-Allow-Origin=*, got %q", got)
}
}
func TestBodyLimit(t *testing.T) {
f := newFixture(t)
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB

View File

@@ -26,10 +26,11 @@ func NewLNbits(baseURL, invoiceKey string) *LNbitsClient {
}
type createReq struct {
Out bool `json:"out"`
Amount int64 `json:"amount"`
Memo string `json:"memo"`
Expiry int `json:"expiry,omitempty"`
Out bool `json:"out"`
Amount int64 `json:"amount"`
Memo string `json:"memo"`
Expiry int `json:"expiry,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
type createResp struct {
@@ -47,8 +48,8 @@ type statusResp struct {
} `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})
func (c *LNbitsClient) Create(ctx context.Context, amountSats int64, memo string, expirySecs int, extra map[string]string) (string, string, error) {
body, err := json.Marshal(createReq{Out: false, Amount: amountSats, Memo: memo, Expiry: expirySecs, Extra: extra})
if err != nil {
return "", "", err
}

View File

@@ -20,6 +20,8 @@ type Pricing struct {
YearlySats int64
LifetimeSats int64
ExpiryMins int
MemoYearly string
MemoLifetime string
}
type Service struct {
@@ -131,17 +133,15 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoic
}
amount := s.pricing.YearlySats * int64(req.Years)
memo := s.pricing.MemoYearly
if req.SubscriptionType == user.SubLifetime {
amount = s.pricing.LifetimeSats
}
memo := fmt.Sprintf("%s@%s", username, s.domain)
if isRenewal {
memo = "renewal: " + memo
memo = s.pricing.MemoLifetime
}
expirySecs := s.pricing.ExpiryMins * 60
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs)
extra := map[string]string{"pubkey": req.Pubkey}
hash, request, err := s.lnbits.Create(ctx, amount, memo, expirySecs, extra)
if err != nil {
return nil, err
}

View File

@@ -133,6 +133,20 @@ func (r *Repo) Delete(ctx context.Context, pubkey string) error {
return err
}
// ResetAllSyncFlags clears manual_username and last_synced_at for every active
// user so the profile sync worker re-evaluates them on its next tick. Returns
// the number of affected rows.
func (r *Repo) ResetAllSyncFlags(ctx context.Context) (int64, error) {
res, err := r.db.ExecContext(ctx, `UPDATE users SET
manual_username = 0,
last_synced_at = NULL
WHERE is_active = 1`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// 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 {

View File

@@ -100,6 +100,29 @@ func (s *Service) Delete(ctx context.Context, pubkey string) error {
return s.repo.Delete(ctx, pubkey)
}
// ResetUsername clears the manual_username pin and last_synced_at cooldown for
// a single user so the next profile sync cycle re-evaluates their kind:0
// metadata. The stored username is left untouched until the worker overwrites
// it. Returns the updated user.
func (s *Service) ResetUsername(ctx context.Context, pubkey string) (*User, error) {
u, err := s.repo.GetByPubkey(ctx, pubkey)
if err != nil {
return nil, err
}
u.ManualUsername = false
u.LastSyncedAt = nil
if err := s.repo.Update(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// ResetAllUsernames clears manual_username and last_synced_at for every active
// user. Returns the number of affected rows.
func (s *Service) ResetAllUsernames(ctx context.Context) (int64, error) {
return s.repo.ResetAllSyncFlags(ctx)
}
// computeExpiry returns *time.Time (nil for lifetime).
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
if sub == SubLifetime {