Compare commits
2 Commits
a01797e9b2
...
14fcce50af
| Author | SHA1 | Date | |
|---|---|---|---|
| 14fcce50af | |||
| 611ef5fc4a |
@@ -108,6 +108,19 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Pricing' }
|
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:
|
/v1/invoices:
|
||||||
post:
|
post:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -209,12 +222,12 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [pubkey, username, subscription_type]
|
required: [pubkey]
|
||||||
properties:
|
properties:
|
||||||
pubkey: { type: string }
|
pubkey: { type: string }
|
||||||
username: { type: string }
|
username: { type: string, description: "Optional NIP-05 username to assign" }
|
||||||
subscription_type: { type: string, enum: [yearly, lifetime] }
|
subscription_type: { type: string, enum: [yearly, lifetime], description: "Defaults to lifetime when omitted" }
|
||||||
years: { type: integer }
|
years: { type: integer, description: "Required when subscription_type is yearly" }
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Created
|
description: Created
|
||||||
|
|||||||
@@ -42,22 +42,28 @@ func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sub := user.SubscriptionType(body.SubscriptionType)
|
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")
|
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
years := body.Years
|
years := body.Years
|
||||||
if years <= 0 {
|
if sub == user.SubYearly && years <= 0 {
|
||||||
years = 1
|
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 {
|
if existing, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk); err == nil && existing != nil {
|
||||||
WriteError(w, http.StatusConflict, "Conflict", "user already exists")
|
WriteError(w, http.StatusConflict, "Conflict", "user already exists")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if existing, err := h.Users.Repo().GetByUsername(r.Context(), user.NormalizeUsername(body.Username)); err == nil && existing != nil {
|
if body.Username != "" {
|
||||||
WriteError(w, http.StatusConflict, "Conflict", "username taken")
|
if existing, err := h.Users.Repo().GetByUsername(r.Context(), user.NormalizeUsername(body.Username)); err == nil && existing != nil {
|
||||||
return
|
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, true)
|
||||||
|
|||||||
@@ -61,3 +61,12 @@ func (h *Users) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
WriteJSON(w, http.StatusOK, resp)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func NewServer(d Deps) *http.Server {
|
|||||||
|
|
||||||
r.Route("/v1", func(r chi.Router) {
|
r.Route("/v1", func(r chi.Router) {
|
||||||
r.Get("/pricing", pricing.Handle)
|
r.Get("/pricing", pricing.Handle)
|
||||||
|
r.Get("/whitelist/pubkeys", users.ListWhitelistedPubkeys)
|
||||||
r.Get("/users/{pubkey}", users.Get)
|
r.Get("/users/{pubkey}", users.Get)
|
||||||
r.Get("/usernames/{name}/available", usernames.Available)
|
r.Get("/usernames/{name}/available", usernames.Available)
|
||||||
if d.Invoices != nil {
|
if d.Invoices != nil {
|
||||||
|
|||||||
@@ -162,6 +162,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) {
|
func TestUsernameAvailability(t *testing.T) {
|
||||||
f := newFixture(t)
|
f := newFixture(t)
|
||||||
resp, body := f.get(t, "/v1/usernames/alice/available")
|
resp, body := f.get(t, "/v1/usernames/alice/available")
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ func (r *Repo) ActiveByName(ctx context.Context) (map[string]string, error) {
|
|||||||
return out, rows.Err()
|
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 {
|
func (r *Repo) collect(rows interface {
|
||||||
Next() bool
|
Next() bool
|
||||||
Scan(...any) error
|
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).
|
// 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) {
|
func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) {
|
||||||
username = NormalizeUsername(username)
|
username = NormalizeUsername(username)
|
||||||
if err := ValidateUsername(username, s.reserved); err != nil {
|
if username != "" {
|
||||||
return nil, err
|
if err := ValidateUsername(username, s.reserved); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
expiresAt := computeExpiry(sub, years, time.Time{}, now)
|
expiresAt := computeExpiry(sub, years, time.Time{}, now)
|
||||||
|
|||||||
Reference in New Issue
Block a user