From fe2b95258dba4db21b2f909f53c7437111417b85 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 6 May 2026 19:31:13 +0000 Subject: [PATCH] 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. --- internal/audit/audit.go | 2 + internal/http/docs/openapi.yaml | 42 +++++++ internal/http/handlers/admin_users.go | 33 +++++ internal/http/server.go | 2 + internal/http/server_test.go | 174 ++++++++++++++++++++++++++ internal/user/repo.go | 14 +++ internal/user/service.go | 23 ++++ 7 files changed, 290 insertions(+) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index a1e6c28..5621797 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -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" diff --git a/internal/http/docs/openapi.yaml b/internal/http/docs/openapi.yaml index 5098554..f1d7b7a 100644 --- a/internal/http/docs/openapi.yaml +++ b/internal/http/docs/openapi.yaml @@ -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] diff --git a/internal/http/handlers/admin_users.go b/internal/http/handlers/admin_users.go index 696f02d..40ea4ce 100644 --- a/internal/http/handlers/admin_users.go +++ b/internal/http/handlers/admin_users.go @@ -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 { diff --git a/internal/http/server.go b/internal/http/server.go index 1e5ecd9..be0414d 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -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) }) }) diff --git a/internal/http/server_test.go b/internal/http/server_test.go index e7714aa..920c4f3 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -3,6 +3,7 @@ package http_test import ( "bytes" "context" + "database/sql" "encoding/json" "net/http" "net/http/httptest" @@ -353,6 +354,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") diff --git a/internal/user/repo.go b/internal/user/repo.go index 8fa067a..1904a7d 100644 --- a/internal/user/repo.go +++ b/internal/user/repo.go @@ -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 { diff --git a/internal/user/service.go b/internal/user/service.go index 662e617..af1e973 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -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 {