Compare commits
9 Commits
a01797e9b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dcd671043 | |||
| 43d78862e3 | |||
| fe2b95258d | |||
| c6bdb7f825 | |||
| 5b234b6b61 | |||
| bbfc64733a | |||
| 7a1ceb49c3 | |||
| 14fcce50af | |||
| 611ef5fc4a |
11
.env.example
11
.env.example
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
internal/db/migrations/0003_reset_manual_username.sql
Normal file
7
internal/db/migrations/0003_reset_manual_username.sql
Normal 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;
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
//go:embed openapi.yaml
|
||||
var openapiYAML []byte
|
||||
|
||||
//go:embed swagger-ui-bundle.js
|
||||
var swaggerUIBundleJS []byte
|
||||
|
||||
//go:embed swagger-ui.css
|
||||
var swaggerUICSS []byte
|
||||
|
||||
var openapiJSON []byte
|
||||
|
||||
func init() {
|
||||
@@ -70,11 +76,11 @@ const swaggerHTML = `<!DOCTYPE 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" />
|
||||
<link rel="stylesheet" href="/docs/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 src="/docs/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
@@ -91,3 +97,15 @@ func ServeUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(swaggerHTML))
|
||||
}
|
||||
|
||||
func ServeJS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write(swaggerUIBundleJS)
|
||||
}
|
||||
|
||||
func ServeCSS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write(swaggerUICSS)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,19 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Pricing' }
|
||||
/v1/whitelist/pubkeys:
|
||||
get:
|
||||
tags: [Public]
|
||||
summary: Active whitelist pubkeys
|
||||
description: Hex-encoded pubkeys for all active (whitelisted) subscribers.
|
||||
responses:
|
||||
'200':
|
||||
description: Pubkey list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { type: string, description: Hex pubkey }
|
||||
/v1/invoices:
|
||||
post:
|
||||
tags: [User]
|
||||
@@ -209,12 +222,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [pubkey, username, subscription_type]
|
||||
required: [pubkey]
|
||||
properties:
|
||||
pubkey: { type: string }
|
||||
username: { type: string }
|
||||
subscription_type: { type: string, enum: [yearly, lifetime] }
|
||||
years: { type: integer }
|
||||
username: { type: string, description: "Optional NIP-05 username to assign" }
|
||||
subscription_type: { type: string, enum: [yearly, lifetime], description: "Defaults to lifetime when omitted" }
|
||||
years: { type: integer, description: "Required when subscription_type is yearly" }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
@@ -277,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]
|
||||
|
||||
2
internal/http/docs/swagger-ui-bundle.js
Normal file
2
internal/http/docs/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
internal/http/docs/swagger-ui.css
Normal file
3
internal/http/docs/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
@@ -42,25 +42,31 @@ func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
sub := user.SubscriptionType(body.SubscriptionType)
|
||||
if !sub.Valid() {
|
||||
if body.SubscriptionType == "" {
|
||||
sub = user.SubLifetime
|
||||
} else if !sub.Valid() {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
|
||||
return
|
||||
}
|
||||
|
||||
years := body.Years
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
if sub == user.SubYearly && years <= 0 {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "years is required for yearly subscription")
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
if body.Username != "" {
|
||||
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)
|
||||
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")
|
||||
@@ -138,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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ type NostrJSON struct {
|
||||
}
|
||||
|
||||
func (h *NostrJSON) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
|
||||
names, err := h.Users.Repo().ActiveByName(r.Context())
|
||||
|
||||
@@ -61,3 +61,12 @@ func (h *Users) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Users) ListWhitelistedPubkeys(w http.ResponseWriter, r *http.Request) {
|
||||
pubkeys, err := h.Users.Repo().ListActivePubkeys(r.Context())
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, pubkeys)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,12 @@ func NewServer(d Deps) *http.Server {
|
||||
r.Get("/openapi.json", docs.ServeJSON)
|
||||
r.Get("/docs", docs.ServeUI)
|
||||
r.Get("/docs/", docs.ServeUI)
|
||||
r.Get("/docs/swagger-ui-bundle.js", docs.ServeJS)
|
||||
r.Get("/docs/swagger-ui.css", docs.ServeCSS)
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Get("/pricing", pricing.Handle)
|
||||
r.Get("/whitelist/pubkeys", users.ListWhitelistedPubkeys)
|
||||
r.Get("/users/{pubkey}", users.Get)
|
||||
r.Get("/usernames/{name}/available", usernames.Available)
|
||||
if d.Invoices != nil {
|
||||
@@ -76,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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -162,6 +164,53 @@ func TestNostrJSON_EmptyAndPopulated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistPubkeys(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
resp, body := f.get(t, "/v1/whitelist/pubkeys")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("empty list status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var empty []string
|
||||
if err := json.Unmarshal(body, &empty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Errorf("expected empty array: %s", body)
|
||||
}
|
||||
|
||||
f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||
"pubkey": testHex, "username": "alice",
|
||||
"subscription_type": "yearly", "years": 1,
|
||||
})
|
||||
|
||||
inactiveHex := "1f8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1d"
|
||||
_, err := f.db.ExecContext(context.Background(),
|
||||
`INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, created_at)
|
||||
VALUES (?, 'bob', 'yearly', NULL, 0, 1, datetime('now'))`,
|
||||
inactiveHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body = f.get(t, "/v1/whitelist/pubkeys")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var pubkeys []string
|
||||
if err := json.Unmarshal(body, &pubkeys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pubkeys) != 1 || pubkeys[0] != testHex {
|
||||
t.Errorf("want [%s], got %v (body %s)", testHex, pubkeys, body)
|
||||
}
|
||||
for _, pk := range pubkeys {
|
||||
if pk == inactiveHex {
|
||||
t.Errorf("inactive pubkey should not appear: %v", pubkeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameAvailability(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/v1/usernames/alice/available")
|
||||
@@ -306,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")
|
||||
@@ -332,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ var (
|
||||
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}$`)
|
||||
// Username rules: 1-30 chars, [a-z0-9_.-], lowercase, must start with alnum.
|
||||
// Dot is allowed per NIP-05 local-part spec.
|
||||
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))
|
||||
|
||||
@@ -10,9 +10,12 @@ func TestValidateUsername(t *testing.T) {
|
||||
{"alice", true},
|
||||
{"al-ice_42", true},
|
||||
{"a", true},
|
||||
{"alice.bob", true},
|
||||
{"alice.smith.42", true},
|
||||
{"", false},
|
||||
{"-alice", false},
|
||||
{"_alice", false},
|
||||
{".alice", false},
|
||||
{"thisusernameiswaytoolongtobevalid12345", false},
|
||||
{"admin", false},
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
// SanitizeForUsername coerces an arbitrary profile string into a candidate
|
||||
// that matches usernameRE: lowercase ASCII alphanumerics, `_`, and `-`,
|
||||
// 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 {
|
||||
@@ -22,7 +22,7 @@ func SanitizeForUsername(s string) string {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
prevSep = false
|
||||
case r == '-' || r == '_':
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
if b.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -42,9 +42,9 @@ func SanitizeForUsername(s string) string {
|
||||
prevSep = true
|
||||
}
|
||||
}
|
||||
out := strings.TrimRight(b.String(), "_-")
|
||||
out := strings.TrimRight(b.String(), "_-.")
|
||||
if len(out) > 30 {
|
||||
out = strings.TrimRight(out[:30], "_-")
|
||||
out = strings.TrimRight(out[:30], "_-.")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ func TestSanitizeForUsername(t *testing.T) {
|
||||
{"alice_", "alice"},
|
||||
{"alice--bob", "alice-bob"},
|
||||
{"alice__bob", "alice_bob"},
|
||||
{"alice.bob", "alice.bob"},
|
||||
{"alice..bob", "alice.bob"},
|
||||
{".alice", "alice"},
|
||||
{"alice.", "alice"},
|
||||
{" ", ""},
|
||||
{"", ""},
|
||||
{"!!!", ""},
|
||||
@@ -59,6 +63,11 @@ func TestCandidateFromMetadataPrecedence(t *testing.T) {
|
||||
name: "alice", nip05: "preferred@azzamo.net", dom: domain,
|
||||
want: "preferred",
|
||||
},
|
||||
{
|
||||
desc: "nip05 local part with dot is preserved",
|
||||
name: "alice", nip05: "alice.smith@azzamo.net", dom: domain,
|
||||
want: "alice.smith",
|
||||
},
|
||||
{
|
||||
desc: "nip05 ignored when domain differs",
|
||||
name: "alice", nip05: "preferred@other.example", dom: domain,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -50,6 +50,24 @@ func (r *Repo) ActiveByName(ctx context.Context) (map[string]string, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) ListActivePubkeys(ctx context.Context) ([]string, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT pubkey FROM users WHERE is_active = 1 ORDER BY pubkey`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var pk string
|
||||
if err := rows.Scan(&pk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pk)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) collect(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
|
||||
@@ -39,8 +39,10 @@ func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error
|
||||
// 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
|
||||
if username != "" {
|
||||
if err := ValidateUsername(username, s.reserved); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expiresAt := computeExpiry(sub, years, time.Time{}, now)
|
||||
@@ -98,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 {
|
||||
|
||||
Reference in New Issue
Block a user