first commit
This commit is contained in:
93
internal/http/docs/docs.go
Normal file
93
internal/http/docs/docs.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed openapi.yaml
|
||||
var openapiYAML []byte
|
||||
|
||||
var openapiJSON []byte
|
||||
|
||||
func init() {
|
||||
var raw any
|
||||
if err := yaml.Unmarshal(openapiYAML, &raw); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
clean := convertMaps(raw)
|
||||
b, err := json.Marshal(clean)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
openapiJSON = b
|
||||
}
|
||||
|
||||
// convertMaps recursively converts map[interface{}]interface{} to map[string]interface{}.
|
||||
func convertMaps(in any) any {
|
||||
switch v := in.(type) {
|
||||
case map[any]any:
|
||||
m := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
m[toString(k)] = convertMaps(val)
|
||||
}
|
||||
return m
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(v))
|
||||
for k, val := range v {
|
||||
m[k] = convertMaps(val)
|
||||
}
|
||||
return m
|
||||
case []any:
|
||||
out := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
out[i] = convertMaps(item)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ServeJSON(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
_, _ = w.Write(openapiJSON)
|
||||
}
|
||||
|
||||
const swaggerHTML = `<!DOCTYPE html>
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func ServeUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(swaggerHTML))
|
||||
}
|
||||
297
internal/http/docs/openapi.yaml
Normal file
297
internal/http/docs/openapi.yaml
Normal file
@@ -0,0 +1,297 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: NIP-05 API
|
||||
description: Single-domain NIP-05 identity service with Lightning-paid registration.
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: /
|
||||
tags:
|
||||
- name: Public
|
||||
description: Anonymous, infrastructure-level endpoints. Cacheable, no auth.
|
||||
- name: User
|
||||
description: Anonymous flows for end users — lookup, availability, payment.
|
||||
- name: Admin
|
||||
description: Privileged operations. Require an `X-API-Key` header.
|
||||
components:
|
||||
securitySchemes:
|
||||
AdminAPIKey:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
detail: { type: string }
|
||||
Pricing:
|
||||
type: object
|
||||
properties:
|
||||
yearly_sats: { type: integer }
|
||||
lifetime_sats: { type: integer }
|
||||
lightning_enabled: { type: boolean }
|
||||
Invoice:
|
||||
type: object
|
||||
properties:
|
||||
payment_hash: { type: string }
|
||||
payment_request: { type: string }
|
||||
amount_sats: { type: integer }
|
||||
expires_at: { type: string, format: date-time }
|
||||
username: { type: string }
|
||||
is_renewal: { type: boolean }
|
||||
InvoiceStatus:
|
||||
type: object
|
||||
properties:
|
||||
payment_hash: { type: string }
|
||||
status: { type: string, enum: [pending, paid, expired] }
|
||||
username: { type: string }
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
pubkey: { type: string }
|
||||
npub: { type: string }
|
||||
username: { type: string }
|
||||
subscription_type: { type: string, enum: [yearly, lifetime] }
|
||||
is_active: { type: boolean }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
deactivated_at: { type: string, format: date-time, nullable: true }
|
||||
UserLookup:
|
||||
type: object
|
||||
properties:
|
||||
pubkey: { type: string }
|
||||
npub: { type: string }
|
||||
is_whitelisted: { type: boolean }
|
||||
username: { type: string }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
in_grace: { type: boolean }
|
||||
reserved_username: { type: string }
|
||||
expired_at: { type: string, format: date-time }
|
||||
paths:
|
||||
/.well-known/nostr.json:
|
||||
get:
|
||||
tags: [Public]
|
||||
summary: NIP-05 lookup
|
||||
parameters:
|
||||
- in: query
|
||||
name: name
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: NIP-05 names map
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
names:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
relays:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items: { type: string }
|
||||
/healthz:
|
||||
get:
|
||||
tags: [Public]
|
||||
summary: Health check
|
||||
responses:
|
||||
'200': { description: OK }
|
||||
'503': { description: Down }
|
||||
/v1/pricing:
|
||||
get:
|
||||
tags: [Public]
|
||||
summary: Pricing info
|
||||
responses:
|
||||
'200':
|
||||
description: Pricing
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Pricing' }
|
||||
/v1/invoices:
|
||||
post:
|
||||
tags: [User]
|
||||
summary: Create payment invoice
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [pubkey]
|
||||
properties:
|
||||
pubkey: { type: string, description: "Hex pubkey or npub" }
|
||||
username:
|
||||
type: string
|
||||
description: "Optional. Auto-generated from the pubkey if omitted."
|
||||
subscription_type:
|
||||
type: string
|
||||
enum: [yearly, lifetime]
|
||||
description: "Optional. Defaults to lifetime."
|
||||
years:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: "Optional. Defaults to 1 when subscription_type is yearly; ignored for lifetime."
|
||||
responses:
|
||||
'200':
|
||||
description: Invoice
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Invoice' }
|
||||
'400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
|
||||
'403': { description: Forbidden — user already has lifetime access, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
|
||||
'409': { description: Conflict — username unavailable or pending invoice already exists, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
|
||||
'503': { description: Lightning unavailable, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
|
||||
/v1/invoices/{payment_hash}:
|
||||
get:
|
||||
tags: [User]
|
||||
summary: Invoice status
|
||||
parameters:
|
||||
- in: path
|
||||
name: payment_hash
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Status
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/InvoiceStatus' }
|
||||
'404': { description: Not found }
|
||||
/v1/users/{pubkey}:
|
||||
get:
|
||||
tags: [User]
|
||||
summary: Lookup user by pubkey (npub or hex)
|
||||
parameters:
|
||||
- in: path
|
||||
name: pubkey
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: User lookup
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/UserLookup' }
|
||||
'404': { description: Never registered }
|
||||
/v1/usernames/{name}/available:
|
||||
get:
|
||||
tags: [User]
|
||||
summary: Username availability
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Availability
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
username: { type: string }
|
||||
available: { type: boolean }
|
||||
/v1/admin/users:
|
||||
post:
|
||||
tags: [Admin]
|
||||
summary: Add user (admin)
|
||||
security: [{ AdminAPIKey: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [pubkey, username, subscription_type]
|
||||
properties:
|
||||
pubkey: { type: string }
|
||||
username: { type: string }
|
||||
subscription_type: { type: string, enum: [yearly, lifetime] }
|
||||
years: { type: integer }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/User' } } }
|
||||
'401': { description: Unauthorized }
|
||||
get:
|
||||
tags: [Admin]
|
||||
summary: List users (admin)
|
||||
security: [{ AdminAPIKey: [] }]
|
||||
parameters:
|
||||
- in: query
|
||||
name: active
|
||||
schema: { type: boolean }
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: User list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/User' }
|
||||
'401': { description: Unauthorized }
|
||||
/v1/admin/users/{pubkey}:
|
||||
put:
|
||||
tags: [Admin]
|
||||
summary: Update username (admin)
|
||||
security: [{ AdminAPIKey: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: pubkey
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [username]
|
||||
properties:
|
||||
username: { type: string }
|
||||
responses:
|
||||
'200': { description: Updated }
|
||||
'401': { description: Unauthorized }
|
||||
'404': { description: Not found }
|
||||
'409': { description: Conflict }
|
||||
delete:
|
||||
tags: [Admin]
|
||||
summary: Delete user (admin)
|
||||
security: [{ AdminAPIKey: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: pubkey
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200': { description: Deleted }
|
||||
'401': { description: Unauthorized }
|
||||
'404': { description: Not found }
|
||||
/v1/admin/users/{pubkey}/extend:
|
||||
post:
|
||||
tags: [Admin]
|
||||
summary: Extend subscription (admin)
|
||||
security: [{ AdminAPIKey: [] }]
|
||||
parameters:
|
||||
- in: path
|
||||
name: pubkey
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
years: { type: integer }
|
||||
subscription_type: { type: string, enum: [yearly, lifetime] }
|
||||
responses:
|
||||
'200': { description: Extended }
|
||||
'401': { description: Unauthorized }
|
||||
'404': { description: Not found }
|
||||
80
internal/http/handlers/admin_extend.go
Normal file
80
internal/http/handlers/admin_extend.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/noderunners/nip05api/internal/audit"
|
||||
"github.com/noderunners/nip05api/internal/dm"
|
||||
"github.com/noderunners/nip05api/internal/nostr"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
"github.com/noderunners/nip05api/internal/webhook"
|
||||
)
|
||||
|
||||
type AdminExtend struct {
|
||||
Users *user.Service
|
||||
DMs *dm.Service
|
||||
Hooks *webhook.Service
|
||||
Audit *audit.Logger
|
||||
Domain string
|
||||
Frontend string
|
||||
}
|
||||
|
||||
type extendReq struct {
|
||||
Years int `json:"years"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
}
|
||||
|
||||
func (h *AdminExtend) Handle(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
|
||||
}
|
||||
var body extendReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sub := u.SubscriptionType
|
||||
if body.SubscriptionType != "" {
|
||||
s := user.SubscriptionType(body.SubscriptionType)
|
||||
if !s.Valid() {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
|
||||
return
|
||||
}
|
||||
sub = s
|
||||
}
|
||||
years := body.Years
|
||||
if sub == user.SubYearly && years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
|
||||
if err := h.Users.Renew(r.Context(), u, sub, years); err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
vars := dmVars(u, h.Domain, h.Frontend)
|
||||
_ = h.DMs.Send(r.Context(), dm.EventExtended, u.Pubkey, vars)
|
||||
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserExtended, hookData(u, h.Domain))
|
||||
h.Audit.Log(r.Context(), audit.ActionUserExtended, audit.ActorAdmin, u.Pubkey, map[string]any{
|
||||
"subscription_type": string(sub),
|
||||
"years": years,
|
||||
})
|
||||
|
||||
WriteJSON(w, http.StatusOK, userResponse(u))
|
||||
}
|
||||
57
internal/http/handlers/admin_helpers.go
Normal file
57
internal/http/handlers/admin_helpers.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/noderunners/nip05api/internal/nostr"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
)
|
||||
|
||||
func userResponse(u *user.User) map[string]any {
|
||||
resp := map[string]any{
|
||||
"pubkey": u.Pubkey,
|
||||
"npub": nostr.HexToNpub(u.Pubkey),
|
||||
"username": u.Username,
|
||||
"subscription_type": string(u.SubscriptionType),
|
||||
"is_active": u.IsActive,
|
||||
"manual_username": u.ManualUsername,
|
||||
"created_at": u.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if u.ExpiresAt != nil {
|
||||
resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if u.DeactivatedAt != nil {
|
||||
resp["deactivated_at"] = u.DeactivatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func dmVars(u *user.User, domain, frontend string) map[string]string {
|
||||
expires := "lifetime"
|
||||
if u.ExpiresAt != nil {
|
||||
expires = u.ExpiresAt.Format("2006-01-02")
|
||||
}
|
||||
return map[string]string{
|
||||
"username": u.Username,
|
||||
"npub": nostr.HexToNpub(u.Pubkey),
|
||||
"pubkey": u.Pubkey,
|
||||
"domain": domain,
|
||||
"expires_at": expires,
|
||||
"days_remaining": "",
|
||||
"frontend_url": frontend,
|
||||
"subscription_type": string(u.SubscriptionType),
|
||||
}
|
||||
}
|
||||
|
||||
func hookData(u *user.User, domain string) map[string]any {
|
||||
d := map[string]any{
|
||||
"pubkey": u.Pubkey,
|
||||
"npub": nostr.HexToNpub(u.Pubkey),
|
||||
"username": u.Username,
|
||||
"subscription_type": string(u.SubscriptionType),
|
||||
}
|
||||
if u.ExpiresAt != nil {
|
||||
d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return d
|
||||
}
|
||||
166
internal/http/handlers/admin_users.go
Normal file
166
internal/http/handlers/admin_users.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/noderunners/nip05api/internal/audit"
|
||||
"github.com/noderunners/nip05api/internal/dm"
|
||||
"github.com/noderunners/nip05api/internal/nostr"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
"github.com/noderunners/nip05api/internal/webhook"
|
||||
)
|
||||
|
||||
type AdminUsers struct {
|
||||
Users *user.Service
|
||||
DMs *dm.Service
|
||||
Hooks *webhook.Service
|
||||
Audit *audit.Logger
|
||||
Domain string
|
||||
Frontend string
|
||||
}
|
||||
|
||||
type adminAddReq struct {
|
||||
Pubkey string `json:"pubkey"`
|
||||
Username string `json:"username"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
Years int `json:"years"`
|
||||
}
|
||||
|
||||
func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminAddReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
|
||||
return
|
||||
}
|
||||
hexpk, err := nostr.NormalizePubkey(body.Pubkey)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
|
||||
return
|
||||
}
|
||||
sub := user.SubscriptionType(body.SubscriptionType)
|
||||
if !sub.Valid() {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
|
||||
return
|
||||
}
|
||||
years := body.Years
|
||||
if years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, true)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrInvalidUsername) {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username")
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
vars := dmVars(u, h.Domain, h.Frontend)
|
||||
_ = h.DMs.Send(r.Context(), dm.EventWelcome, u.Pubkey, vars)
|
||||
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserAdded, hookData(u, h.Domain))
|
||||
h.Audit.Log(r.Context(), audit.ActionUserAdded, audit.ActorAdmin, u.Pubkey, map[string]any{
|
||||
"username": u.Username,
|
||||
"subscription_type": string(u.SubscriptionType),
|
||||
"years": years,
|
||||
})
|
||||
|
||||
WriteJSON(w, http.StatusCreated, userResponse(u))
|
||||
}
|
||||
|
||||
func (h *AdminUsers) List(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
activeOnly := q.Get("active") == "true"
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
users, err := h.Users.Repo().List(r.Context(), user.ListFilter{
|
||||
ActiveOnly: activeOnly,
|
||||
Search: q.Get("q"),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, userResponse(u))
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminUsers) Update(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
|
||||
}
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
|
||||
return
|
||||
}
|
||||
u, err := h.Users.SetUsername(r.Context(), hexpk, body.Username)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, user.ErrUserNotFound):
|
||||
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
|
||||
case errors.Is(err, user.ErrInvalidUsername):
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username")
|
||||
case errors.Is(err, user.ErrUsernameTaken):
|
||||
WriteError(w, http.StatusConflict, "Conflict", "username taken")
|
||||
default:
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
h.Audit.Log(r.Context(), audit.ActionUserUsernameChanged, audit.ActorAdmin, u.Pubkey, map[string]any{
|
||||
"username": u.Username,
|
||||
})
|
||||
WriteJSON(w, http.StatusOK, userResponse(u))
|
||||
}
|
||||
|
||||
func (h *AdminUsers) Delete(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.Repo().GetByPubkey(r.Context(), hexpk)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
WriteError(w, http.StatusNotFound, "NotFound", "user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.Users.Delete(r.Context(), hexpk); err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
_ = h.Hooks.Enqueue(r.Context(), webhook.EventUserRemoved, hookData(u, h.Domain))
|
||||
h.Audit.Log(r.Context(), audit.ActionUserDeleted, audit.ActorAdmin, u.Pubkey, map[string]any{
|
||||
"username": u.Username,
|
||||
})
|
||||
WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
26
internal/http/handlers/health.go
Normal file
26
internal/http/handlers/health.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/noderunners/nip05api/internal/db"
|
||||
)
|
||||
|
||||
type Health struct {
|
||||
DB *db.DB
|
||||
Version string
|
||||
}
|
||||
|
||||
func (h *Health) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.DB.Ping(r.Context()); err != nil {
|
||||
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"status": "down",
|
||||
"version": h.Version,
|
||||
})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"version": h.Version,
|
||||
})
|
||||
}
|
||||
109
internal/http/handlers/invoices.go
Normal file
109
internal/http/handlers/invoices.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/noderunners/nip05api/internal/invoice"
|
||||
"github.com/noderunners/nip05api/internal/nostr"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
)
|
||||
|
||||
type Invoices struct {
|
||||
Service *invoice.Service
|
||||
LightningEnabled bool
|
||||
}
|
||||
|
||||
type createInvoiceReq struct {
|
||||
Username string `json:"username"`
|
||||
Pubkey string `json:"pubkey"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
Years int `json:"years"`
|
||||
}
|
||||
|
||||
func (h *Invoices) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.LightningEnabled {
|
||||
WriteError(w, http.StatusServiceUnavailable, "LightningDisabled", "lightning payments are disabled")
|
||||
return
|
||||
}
|
||||
var body createInvoiceReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON")
|
||||
return
|
||||
}
|
||||
hexpk, err := nostr.NormalizePubkey(body.Pubkey)
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format")
|
||||
return
|
||||
}
|
||||
subStr := strings.TrimSpace(body.SubscriptionType)
|
||||
if subStr == "" {
|
||||
subStr = string(user.SubLifetime)
|
||||
}
|
||||
sub := user.SubscriptionType(subStr)
|
||||
if !sub.Valid() {
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type")
|
||||
return
|
||||
}
|
||||
years := body.Years
|
||||
if sub == user.SubYearly && years <= 0 {
|
||||
years = 1
|
||||
}
|
||||
p, err := h.Service.Create(r.Context(), invoice.CreateRequest{
|
||||
Username: body.Username,
|
||||
Pubkey: hexpk,
|
||||
SubscriptionType: sub,
|
||||
Years: years,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, invoice.ErrLifetimeAccess):
|
||||
WriteError(w, http.StatusForbidden, "User already has lifetime access", "")
|
||||
case errors.Is(err, invoice.ErrPendingInvoiceExists):
|
||||
WriteError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case errors.Is(err, invoice.ErrUsernameTaken),
|
||||
errors.Is(err, user.ErrUsernameTaken):
|
||||
WriteError(w, http.StatusConflict, "Conflict", "username unavailable")
|
||||
case errors.Is(err, invoice.ErrUsernameMismatch):
|
||||
WriteError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case errors.Is(err, user.ErrInvalidUsername),
|
||||
errors.Is(err, invoice.ErrInvalidYears):
|
||||
WriteError(w, http.StatusBadRequest, "ValidationError", err.Error())
|
||||
case errors.Is(err, invoice.ErrLNbits):
|
||||
WriteError(w, http.StatusServiceUnavailable, "LightningError", err.Error())
|
||||
default:
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"payment_hash": p.PaymentHash,
|
||||
"payment_request": p.PaymentRequest,
|
||||
"amount_sats": p.AmountSats,
|
||||
"expires_at": p.ExpiresAt.UTC().Format(time.RFC3339),
|
||||
"username": p.Username,
|
||||
"is_renewal": p.IsRenewal,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Invoices) Get(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "payment_hash")
|
||||
p, err := h.Service.Repo().Get(r.Context(), hash)
|
||||
if errors.Is(err, invoice.ErrInvoiceNotFound) {
|
||||
WriteError(w, http.StatusNotFound, "NotFound", "invoice not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"payment_hash": p.PaymentHash,
|
||||
"status": string(p.Status()),
|
||||
"username": p.Username,
|
||||
})
|
||||
}
|
||||
41
internal/http/handlers/nostrjson.go
Normal file
41
internal/http/handlers/nostrjson.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
)
|
||||
|
||||
type NostrJSON struct {
|
||||
Users *user.Service
|
||||
Relays []string
|
||||
}
|
||||
|
||||
func (h *NostrJSON) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
|
||||
names, err := h.Users.Repo().ActiveByName(r.Context())
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if q := r.URL.Query().Get("name"); q != "" {
|
||||
filtered := map[string]string{}
|
||||
if pk, ok := names[q]; ok {
|
||||
filtered[q] = pk
|
||||
}
|
||||
names = filtered
|
||||
}
|
||||
|
||||
relays := map[string][]string{}
|
||||
if len(h.Relays) > 0 {
|
||||
for _, pk := range names {
|
||||
relays[pk] = h.Relays
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"names": names,
|
||||
"relays": relays,
|
||||
})
|
||||
}
|
||||
17
internal/http/handlers/pricing.go
Normal file
17
internal/http/handlers/pricing.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Pricing struct {
|
||||
YearlySats int64
|
||||
LifetimeSats int64
|
||||
LightningEnabled bool
|
||||
}
|
||||
|
||||
func (h *Pricing) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"yearly_sats": h.YearlySats,
|
||||
"lifetime_sats": h.LifetimeSats,
|
||||
"lightning_enabled": h.LightningEnabled,
|
||||
})
|
||||
}
|
||||
19
internal/http/handlers/respond.go
Normal file
19
internal/http/handlers/respond.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func WriteJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func WriteError(w http.ResponseWriter, code int, kind, detail string) {
|
||||
WriteJSON(w, code, map[string]string{"error": kind, "detail": detail})
|
||||
}
|
||||
40
internal/http/handlers/usernames.go
Normal file
40
internal/http/handlers/usernames.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
)
|
||||
|
||||
type Usernames struct{ Users *user.Service }
|
||||
|
||||
func (h *Usernames) Available(w http.ResponseWriter, r *http.Request) {
|
||||
name := user.NormalizeUsername(chi.URLParam(r, "name"))
|
||||
if err := user.ValidateUsername(name, h.Users.Reserved()); err != nil {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"username": name,
|
||||
"available": false,
|
||||
"reason": "invalid_or_reserved",
|
||||
})
|
||||
return
|
||||
}
|
||||
avail, err := h.Users.IsAvailable(r.Context(), name)
|
||||
if err != nil {
|
||||
if errors.Is(err, user.ErrInvalidUsername) {
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"username": name,
|
||||
"available": false,
|
||||
"reason": "invalid",
|
||||
})
|
||||
return
|
||||
}
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"username": name,
|
||||
"available": avail,
|
||||
})
|
||||
}
|
||||
63
internal/http/handlers/users.go
Normal file
63
internal/http/handlers/users.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/noderunners/nip05api/internal/nostr"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
)
|
||||
|
||||
type Users struct {
|
||||
Users *user.Service
|
||||
GraceDays int
|
||||
}
|
||||
|
||||
func (h *Users) Get(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.Repo().GetByPubkey(r.Context(), hexpk)
|
||||
if errors.Is(err, user.ErrUserNotFound) {
|
||||
WriteError(w, http.StatusNotFound, "NotFound", "user not registered")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "InternalError", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"pubkey": u.Pubkey,
|
||||
"npub": nostr.HexToNpub(u.Pubkey),
|
||||
}
|
||||
if u.IsActive {
|
||||
resp["is_whitelisted"] = true
|
||||
resp["username"] = u.Username
|
||||
if u.ExpiresAt != nil {
|
||||
resp["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
} else {
|
||||
resp["expires_at"] = nil
|
||||
}
|
||||
resp["subscription_type"] = string(u.SubscriptionType)
|
||||
} else {
|
||||
resp["is_whitelisted"] = false
|
||||
if u.ExpiresAt != nil {
|
||||
resp["expired_at"] = u.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if u.DeactivatedAt != nil {
|
||||
cutoff := u.DeactivatedAt.Add(time.Duration(h.GraceDays) * 24 * time.Hour)
|
||||
if time.Now().UTC().Before(cutoff) {
|
||||
resp["in_grace"] = true
|
||||
resp["reserved_username"] = u.Username
|
||||
} else {
|
||||
resp["in_grace"] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
25
internal/http/middleware/adminauth.go
Normal file
25
internal/http/middleware/adminauth.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func AdminAuth(apiKey string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
provided := r.Header.Get("X-API-Key")
|
||||
if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(apiKey)) != 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "Unauthorized",
|
||||
"detail": "missing or invalid X-API-Key",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
19
internal/http/middleware/bodylimit.go
Normal file
19
internal/http/middleware/bodylimit.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// BodyLimit caps request body size. Returns 413 if exceeded.
|
||||
func BodyLimit(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength > maxBytes {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_, _ = w.Write([]byte(`{"error":"PayloadTooLarge","detail":"request body exceeds limit"}`))
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
18
internal/http/middleware/cors.go
Normal file
18
internal/http/middleware/cors.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-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 r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
49
internal/http/middleware/logging.go
Normal file
49
internal/http/middleware/logging.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
applog "github.com/noderunners/nip05api/internal/log"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.Header.Get("X-Request-ID")
|
||||
if id == "" {
|
||||
id = newRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
ctx := applog.WithRequestID(r.Context(), id)
|
||||
rec := &statusRecorder{ResponseWriter: w, status: 200}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, r.WithContext(ctx))
|
||||
slog.Info("http",
|
||||
"request_id", id,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote", r.RemoteAddr,
|
||||
)
|
||||
})
|
||||
}
|
||||
27
internal/http/middleware/ratelimit.go
Normal file
27
internal/http/middleware/ratelimit.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/httprate"
|
||||
)
|
||||
|
||||
// RateLimit returns a middleware that limits requests per minute by IP.
|
||||
// Admin routes are skipped.
|
||||
func RateLimit(perMin int) func(http.Handler) http.Handler {
|
||||
if perMin <= 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
limiter := httprate.LimitByIP(perMin, time.Minute)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/admin/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
limiter(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
34
internal/http/middleware/realip.go
Normal file
34
internal/http/middleware/realip.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RealIP rewrites RemoteAddr from common reverse-proxy headers so downstream
|
||||
// rate limiters and loggers see the original client IP. Trusted unconditionally;
|
||||
// terminate this header at your proxy.
|
||||
func RealIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if ip := clientIP(r); ip != "" {
|
||||
r.RemoteAddr = ip + ":0"
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
first := strings.TrimSpace(strings.SplitN(xff, ",", 2)[0])
|
||||
if parsed := net.ParseIP(first); parsed != nil {
|
||||
return parsed.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
42
internal/http/middleware/recoverer.go
Normal file
42
internal/http/middleware/recoverer.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Recoverer turns panics into 500 JSON responses without leaking the stack to
|
||||
// clients. The full stack is logged at error level.
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rv := recover(); rv != nil {
|
||||
slog.Error("panic recovered",
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
"err", rv,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
if !headerWritten(w) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "InternalError",
|
||||
"detail": "internal server error",
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// headerWritten is best-effort; if the response is hijacked we skip writing.
|
||||
func headerWritten(w http.ResponseWriter) bool {
|
||||
if rw, ok := w.(interface{ Status() int }); ok {
|
||||
return rw.Status() != 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
99
internal/http/server.go
Normal file
99
internal/http/server.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"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/http/docs"
|
||||
"github.com/noderunners/nip05api/internal/http/handlers"
|
||||
"github.com/noderunners/nip05api/internal/http/middleware"
|
||||
"github.com/noderunners/nip05api/internal/invoice"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
"github.com/noderunners/nip05api/internal/webhook"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Cfg *config.Config
|
||||
DB *db.DB
|
||||
Users *user.Service
|
||||
Invoices *invoice.Service
|
||||
DMs *dm.Service
|
||||
Hooks *webhook.Service
|
||||
Audit *audit.Logger
|
||||
Version string
|
||||
}
|
||||
|
||||
func NewServer(d Deps) *http.Server {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logging)
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(middleware.BodyLimit(1 << 20)) // 1 MiB max request body
|
||||
r.Use(middleware.RateLimit(d.Cfg.RateLimitPerMin))
|
||||
|
||||
health := &handlers.Health{DB: d.DB, Version: d.Version}
|
||||
nostrJSON := &handlers.NostrJSON{Users: d.Users, Relays: d.Cfg.Nostr.Relays}
|
||||
pricing := &handlers.Pricing{
|
||||
YearlySats: d.Cfg.Lightning.PriceYearlySats,
|
||||
LifetimeSats: d.Cfg.Lightning.PriceLifetimeSats,
|
||||
LightningEnabled: d.Cfg.Lightning.Enabled,
|
||||
}
|
||||
users := &handlers.Users{Users: d.Users, GraceDays: d.Cfg.Expiry.GraceDays}
|
||||
usernames := &handlers.Usernames{Users: d.Users}
|
||||
invoices := &handlers.Invoices{Service: d.Invoices, LightningEnabled: d.Cfg.Lightning.Enabled}
|
||||
adminUsers := &handlers.AdminUsers{
|
||||
Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit,
|
||||
Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL,
|
||||
}
|
||||
adminExtend := &handlers.AdminExtend{
|
||||
Users: d.Users, DMs: d.DMs, Hooks: d.Hooks, Audit: d.Audit,
|
||||
Domain: d.Cfg.Domain, Frontend: d.Cfg.FrontendURL,
|
||||
}
|
||||
|
||||
r.Get("/healthz", health.Handle)
|
||||
r.Get("/.well-known/nostr.json", nostrJSON.Handle)
|
||||
r.Get("/openapi.json", docs.ServeJSON)
|
||||
r.Get("/docs", docs.ServeUI)
|
||||
r.Get("/docs/", docs.ServeUI)
|
||||
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Get("/pricing", pricing.Handle)
|
||||
r.Get("/users/{pubkey}", users.Get)
|
||||
r.Get("/usernames/{name}/available", usernames.Available)
|
||||
if d.Invoices != nil {
|
||||
r.Post("/invoices", invoices.Create)
|
||||
r.Get("/invoices/{payment_hash}", invoices.Get)
|
||||
}
|
||||
|
||||
r.Route("/admin", func(r chi.Router) {
|
||||
r.Use(middleware.AdminAuth(d.Cfg.AdminAPIKey))
|
||||
r.Post("/users", adminUsers.Add)
|
||||
r.Get("/users", adminUsers.List)
|
||||
r.Put("/users/{pubkey}", adminUsers.Update)
|
||||
r.Delete("/users/{pubkey}", adminUsers.Delete)
|
||||
r.Post("/users/{pubkey}/extend", adminExtend.Handle)
|
||||
})
|
||||
})
|
||||
|
||||
return &http.Server{
|
||||
Addr: d.Cfg.Addr(),
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func Shutdown(ctx context.Context, srv *http.Server) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(shutdownCtx)
|
||||
}
|
||||
357
internal/http/server_test.go
Normal file
357
internal/http/server_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/noderunners/nip05api/internal/audit"
|
||||
"github.com/noderunners/nip05api/internal/config"
|
||||
"github.com/noderunners/nip05api/internal/db"
|
||||
"github.com/noderunners/nip05api/internal/dm"
|
||||
httpapi "github.com/noderunners/nip05api/internal/http"
|
||||
"github.com/noderunners/nip05api/internal/messages"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
"github.com/noderunners/nip05api/internal/webhook"
|
||||
)
|
||||
|
||||
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
|
||||
const testKey = "test-admin-key-twenty-five-chars"
|
||||
|
||||
type fixture struct {
|
||||
srv *httptest.Server
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
d, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Domain: "test.local",
|
||||
Port: 0,
|
||||
AdminAPIKey: testKey,
|
||||
FrontendURL: "https://test.local/nip05",
|
||||
Lightning: config.LightningConfig{Enabled: false},
|
||||
Expiry: config.ExpiryConfig{GraceDays: 30},
|
||||
ReservedUsernames: []string{"admin", "root"},
|
||||
RateLimitPerMin: 0, // disabled in tests
|
||||
}
|
||||
tmpls, _ := messages.Load("/nonexistent.yaml")
|
||||
users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames)
|
||||
dms := dm.NewService(dm.NewRepo(d), tmpls, false)
|
||||
hooks := webhook.NewService(webhook.NewRepo(d), cfg.Domain, false)
|
||||
|
||||
srv := httptest.NewServer(httpapi.NewServer(httpapi.Deps{
|
||||
Cfg: cfg, DB: d, Users: users, DMs: dms, Hooks: hooks,
|
||||
Audit: audit.New(d), Version: "test",
|
||||
}).Handler)
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
_ = d.Close()
|
||||
})
|
||||
return &fixture{srv: srv, db: d}
|
||||
}
|
||||
|
||||
func (f *fixture) get(t *testing.T, path string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(f.srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body := readAll(t, resp)
|
||||
return resp, body
|
||||
}
|
||||
|
||||
func (f *fixture) admin(t *testing.T, method, path string, payload any) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var body []byte
|
||||
if payload != nil {
|
||||
var err error
|
||||
body, err = json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(method, f.srv.URL+path, bytes.NewReader(body))
|
||||
req.Header.Set("X-API-Key", testKey)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp, readAll(t, resp)
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
buf := new(bytes.Buffer)
|
||||
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/healthz")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var got map[string]string
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["status"] != "ok" || got["version"] != "test" {
|
||||
t.Errorf("body: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPricing(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/v1/pricing")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if _, ok := got["yearly_sats"]; !ok {
|
||||
t.Errorf("missing yearly_sats: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNostrJSON_EmptyAndPopulated(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/.well-known/nostr.json")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["names"] == nil {
|
||||
t.Errorf("missing names key: %s", body)
|
||||
}
|
||||
|
||||
f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||
"pubkey": testHex, "username": "alice",
|
||||
"subscription_type": "yearly", "years": 1,
|
||||
})
|
||||
|
||||
_, body = f.get(t, "/.well-known/nostr.json")
|
||||
var populated struct {
|
||||
Names map[string]string `json:"names"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &populated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if populated.Names["alice"] != testHex {
|
||||
t.Errorf("alice not in nostr.json: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameAvailability(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/v1/usernames/alice/available")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["available"] != true {
|
||||
t.Errorf("expected available=true: %s", body)
|
||||
}
|
||||
|
||||
// Reserved name.
|
||||
_, body = f.get(t, "/v1/usernames/admin/available")
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["available"] != false {
|
||||
t.Errorf("admin should be reserved: %s", body)
|
||||
}
|
||||
|
||||
// Invalid format.
|
||||
_, body = f.get(t, "/v1/usernames/-bad/available")
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["available"] != false {
|
||||
t.Errorf("-bad should be invalid: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAuthGate(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, _ := f.get(t, "/v1/admin/users")
|
||||
if resp.StatusCode != 401 {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
resp, _ = f.admin(t, "GET", "/v1/admin/users", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("expected 200 with key, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminLifecycle(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
// Add.
|
||||
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)
|
||||
}
|
||||
|
||||
// Lookup hex.
|
||||
resp, body = f.get(t, "/v1/users/"+testHex)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("lookup status %d", resp.StatusCode)
|
||||
}
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["is_whitelisted"] != true || got["username"] != "alice" {
|
||||
t.Errorf("unexpected lookup body: %s", body)
|
||||
}
|
||||
|
||||
// Lookup npub form.
|
||||
npub := "npub1p6xyr67d2k5d3kewp2xr5juutehh4zuup50z7wjtt3kharu6pvwqjh7065"
|
||||
resp, _ = f.get(t, "/v1/users/"+npub)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("npub lookup status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Username unavailable now.
|
||||
_, body = f.get(t, "/v1/usernames/alice/available")
|
||||
_ = json.Unmarshal(body, &got)
|
||||
if got["available"] != false {
|
||||
t.Errorf("expected unavailable: %s", body)
|
||||
}
|
||||
|
||||
// Extend.
|
||||
resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/extend",
|
||||
map[string]any{"years": 2})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("extend status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Update username.
|
||||
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)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
resp, _ = f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("delete status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Gone.
|
||||
resp, _ = f.get(t, "/v1/users/"+testHex)
|
||||
if resp.StatusCode != 404 {
|
||||
t.Fatalf("expected 404 after delete, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Audit log should reflect every admin action.
|
||||
wantActions := []string{"user.added", "user.extended", "user.username_changed", "user.deleted"}
|
||||
rows, qerr := f.db.Query(`SELECT action FROM audit_log`)
|
||||
if qerr != nil {
|
||||
t.Fatal(qerr)
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var action string
|
||||
if err := rows.Scan(&action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen[action] = true
|
||||
}
|
||||
for _, action := range wantActions {
|
||||
if !seen[action] {
|
||||
t.Errorf("missing audit action %q (got %v)", action, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAdd_BadInputs(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
resp, _ := f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||
"pubkey": "notapubkey", "username": "alice",
|
||||
"subscription_type": "yearly",
|
||||
})
|
||||
if resp.StatusCode != 400 {
|
||||
t.Errorf("bad pubkey: expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = f.admin(t, "POST", "/v1/admin/users", map[string]any{
|
||||
"pubkey": testHex, "username": "admin",
|
||||
"subscription_type": "yearly",
|
||||
})
|
||||
if resp.StatusCode == 200 || resp.StatusCode == 201 {
|
||||
t.Errorf("reserved username accepted: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPI(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/openapi.json")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
var spec map[string]any
|
||||
if err := json.Unmarshal(body, &spec); err != nil {
|
||||
t.Fatalf("openapi not valid json: %v", err)
|
||||
}
|
||||
if spec["openapi"] == nil {
|
||||
t.Errorf("missing openapi field: %s", body[:min(200, len(body))])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsPage(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
resp, body := f.get(t, "/docs")
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if !bytes.Contains(body, []byte("swagger-ui")) {
|
||||
t.Errorf("expected swagger UI markup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyLimit(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB
|
||||
body := []byte(`{"pubkey":"` + testHex + `","username":"alice","subscription_type":"yearly","data":"` + string(huge) + `"}`)
|
||||
req, _ := http.NewRequest("POST", f.srv.URL+"/v1/admin/users", bytes.NewReader(body))
|
||||
req.Header.Set("X-API-Key", testKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 413 {
|
||||
t.Errorf("expected 413, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user