Spaces:
Paused
Paused
| /** | |
| * Nostr Profile HTTP Handler | |
| * | |
| * Handles HTTP requests for profile management: | |
| * - PUT /api/channels/nostr/:accountId/profile - Update and publish profile | |
| * - POST /api/channels/nostr/:accountId/profile/import - Import from relays | |
| * - GET /api/channels/nostr/:accountId/profile - Get current profile state | |
| */ | |
| import type { IncomingMessage, ServerResponse } from "node:http"; | |
| import { z } from "zod"; | |
| import { publishNostrProfile, getNostrProfileState } from "./channel.js"; | |
| import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; | |
| import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; | |
| // ============================================================================ | |
| // Types | |
| // ============================================================================ | |
| export interface NostrProfileHttpContext { | |
| /** Get current profile from config */ | |
| getConfigProfile: (accountId: string) => NostrProfile | undefined; | |
| /** Update profile in config (after successful publish) */ | |
| updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise<void>; | |
| /** Get account's public key and relays */ | |
| getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null; | |
| /** Logger */ | |
| log?: { | |
| info: (msg: string) => void; | |
| warn: (msg: string) => void; | |
| error: (msg: string) => void; | |
| }; | |
| } | |
| // ============================================================================ | |
| // Rate Limiting | |
| // ============================================================================ | |
| interface RateLimitEntry { | |
| count: number; | |
| windowStart: number; | |
| } | |
| const rateLimitMap = new Map<string, RateLimitEntry>(); | |
| const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute | |
| const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute | |
| function checkRateLimit(accountId: string): boolean { | |
| const now = Date.now(); | |
| const entry = rateLimitMap.get(accountId); | |
| if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { | |
| rateLimitMap.set(accountId, { count: 1, windowStart: now }); | |
| return true; | |
| } | |
| if (entry.count >= RATE_LIMIT_MAX_REQUESTS) { | |
| return false; | |
| } | |
| entry.count++; | |
| return true; | |
| } | |
| // ============================================================================ | |
| // Mutex for Concurrent Publish Prevention | |
| // ============================================================================ | |
| const publishLocks = new Map<string, Promise<void>>(); | |
| async function withPublishLock<T>(accountId: string, fn: () => Promise<T>): Promise<T> { | |
| // Atomic mutex using promise chaining - prevents TOCTOU race condition | |
| const prev = publishLocks.get(accountId) ?? Promise.resolve(); | |
| let resolve: () => void; | |
| const next = new Promise<void>((r) => { | |
| resolve = r; | |
| }); | |
| // Atomically replace the lock before awaiting - any concurrent request | |
| // will now wait on our `next` promise | |
| publishLocks.set(accountId, next); | |
| // Wait for previous operation to complete | |
| await prev.catch(() => {}); | |
| try { | |
| return await fn(); | |
| } finally { | |
| resolve!(); | |
| // Clean up if we're the last in chain | |
| if (publishLocks.get(accountId) === next) { | |
| publishLocks.delete(accountId); | |
| } | |
| } | |
| } | |
| // ============================================================================ | |
| // SSRF Protection | |
| // ============================================================================ | |
| // Block common private/internal hostnames (quick string check) | |
| const BLOCKED_HOSTNAMES = new Set([ | |
| "localhost", | |
| "localhost.localdomain", | |
| "127.0.0.1", | |
| "::1", | |
| "[::1]", | |
| "0.0.0.0", | |
| ]); | |
| // Check if an IP address (resolved) is in a private range | |
| function isPrivateIp(ip: string): boolean { | |
| // Handle IPv4 | |
| const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); | |
| if (ipv4Match) { | |
| const [, a, b] = ipv4Match.map(Number); | |
| // 127.0.0.0/8 (loopback) | |
| if (a === 127) { | |
| return true; | |
| } | |
| // 10.0.0.0/8 (private) | |
| if (a === 10) { | |
| return true; | |
| } | |
| // 172.16.0.0/12 (private) | |
| if (a === 172 && b >= 16 && b <= 31) { | |
| return true; | |
| } | |
| // 192.168.0.0/16 (private) | |
| if (a === 192 && b === 168) { | |
| return true; | |
| } | |
| // 169.254.0.0/16 (link-local) | |
| if (a === 169 && b === 254) { | |
| return true; | |
| } | |
| // 0.0.0.0/8 | |
| if (a === 0) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| // Handle IPv6 | |
| const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, ""); | |
| // ::1 (loopback) | |
| if (ipLower === "::1") { | |
| return true; | |
| } | |
| // fe80::/10 (link-local) | |
| if (ipLower.startsWith("fe80:")) { | |
| return true; | |
| } | |
| // fc00::/7 (unique local) | |
| if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) { | |
| return true; | |
| } | |
| // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4 | |
| const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); | |
| if (v4Mapped) { | |
| return isPrivateIp(v4Mapped[1]); | |
| } | |
| return false; | |
| } | |
| function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } { | |
| try { | |
| const url = new URL(urlStr); | |
| if (url.protocol !== "https:") { | |
| return { ok: false, error: "URL must use https:// protocol" }; | |
| } | |
| const hostname = url.hostname.toLowerCase(); | |
| // Quick hostname block check | |
| if (BLOCKED_HOSTNAMES.has(hostname)) { | |
| return { ok: false, error: "URL must not point to private/internal addresses" }; | |
| } | |
| // Check if hostname is an IP address directly | |
| if (isPrivateIp(hostname)) { | |
| return { ok: false, error: "URL must not point to private/internal addresses" }; | |
| } | |
| // Block suspicious TLDs that resolve to localhost | |
| if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) { | |
| return { ok: false, error: "URL must not point to private/internal addresses" }; | |
| } | |
| return { ok: true }; | |
| } catch { | |
| return { ok: false, error: "Invalid URL format" }; | |
| } | |
| } | |
| // Export for use in import validation | |
| export { validateUrlSafety }; | |
| // ============================================================================ | |
| // Validation Schemas | |
| // ============================================================================ | |
| // NIP-05 format: user@domain.com | |
| const nip05FormatSchema = z | |
| .string() | |
| .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)") | |
| .optional(); | |
| // LUD-16 Lightning address format: user@domain.com | |
| const lud16FormatSchema = z | |
| .string() | |
| .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format") | |
| .optional(); | |
| // Extended profile schema with additional format validation | |
| const ProfileUpdateSchema = NostrProfileSchema.extend({ | |
| nip05: nip05FormatSchema, | |
| lud16: lud16FormatSchema, | |
| }); | |
| // ============================================================================ | |
| // Request Helpers | |
| // ============================================================================ | |
| function sendJson(res: ServerResponse, status: number, body: unknown): void { | |
| res.statusCode = status; | |
| res.setHeader("Content-Type", "application/json; charset=utf-8"); | |
| res.end(JSON.stringify(body)); | |
| } | |
| async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise<unknown> { | |
| return new Promise((resolve, reject) => { | |
| const chunks: Buffer[] = []; | |
| let totalBytes = 0; | |
| req.on("data", (chunk: Buffer) => { | |
| totalBytes += chunk.length; | |
| if (totalBytes > maxBytes) { | |
| reject(new Error("Request body too large")); | |
| req.destroy(); | |
| return; | |
| } | |
| chunks.push(chunk); | |
| }); | |
| req.on("end", () => { | |
| try { | |
| const body = Buffer.concat(chunks).toString("utf-8"); | |
| resolve(body ? JSON.parse(body) : {}); | |
| } catch { | |
| reject(new Error("Invalid JSON")); | |
| } | |
| }); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function parseAccountIdFromPath(pathname: string): string | null { | |
| // Match: /api/channels/nostr/:accountId/profile | |
| const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/); | |
| return match?.[1] ?? null; | |
| } | |
| // ============================================================================ | |
| // HTTP Handler | |
| // ============================================================================ | |
| export function createNostrProfileHttpHandler( | |
| ctx: NostrProfileHttpContext, | |
| ): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> { | |
| return async (req, res) => { | |
| const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); | |
| // Only handle /api/channels/nostr/:accountId/profile paths | |
| if (!url.pathname.startsWith("/api/channels/nostr/")) { | |
| return false; | |
| } | |
| const accountId = parseAccountIdFromPath(url.pathname); | |
| if (!accountId) { | |
| return false; | |
| } | |
| const isImport = url.pathname.endsWith("/profile/import"); | |
| const isProfilePath = url.pathname.endsWith("/profile") || isImport; | |
| if (!isProfilePath) { | |
| return false; | |
| } | |
| // Handle different HTTP methods | |
| try { | |
| if (req.method === "GET" && !isImport) { | |
| return await handleGetProfile(accountId, ctx, res); | |
| } | |
| if (req.method === "PUT" && !isImport) { | |
| return await handleUpdateProfile(accountId, ctx, req, res); | |
| } | |
| if (req.method === "POST" && isImport) { | |
| return await handleImportProfile(accountId, ctx, req, res); | |
| } | |
| // Method not allowed | |
| sendJson(res, 405, { ok: false, error: "Method not allowed" }); | |
| return true; | |
| } catch (err) { | |
| ctx.log?.error(`Profile HTTP error: ${String(err)}`); | |
| sendJson(res, 500, { ok: false, error: "Internal server error" }); | |
| return true; | |
| } | |
| }; | |
| } | |
| // ============================================================================ | |
| // GET /api/channels/nostr/:accountId/profile | |
| // ============================================================================ | |
| async function handleGetProfile( | |
| accountId: string, | |
| ctx: NostrProfileHttpContext, | |
| res: ServerResponse, | |
| ): Promise<true> { | |
| const configProfile = ctx.getConfigProfile(accountId); | |
| const publishState = await getNostrProfileState(accountId); | |
| sendJson(res, 200, { | |
| ok: true, | |
| profile: configProfile ?? null, | |
| publishState: publishState ?? null, | |
| }); | |
| return true; | |
| } | |
| // ============================================================================ | |
| // PUT /api/channels/nostr/:accountId/profile | |
| // ============================================================================ | |
| async function handleUpdateProfile( | |
| accountId: string, | |
| ctx: NostrProfileHttpContext, | |
| req: IncomingMessage, | |
| res: ServerResponse, | |
| ): Promise<true> { | |
| // Rate limiting | |
| if (!checkRateLimit(accountId)) { | |
| sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" }); | |
| return true; | |
| } | |
| // Parse body | |
| let body: unknown; | |
| try { | |
| body = await readJsonBody(req); | |
| } catch (err) { | |
| sendJson(res, 400, { ok: false, error: String(err) }); | |
| return true; | |
| } | |
| // Validate profile | |
| const parseResult = ProfileUpdateSchema.safeParse(body); | |
| if (!parseResult.success) { | |
| const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`); | |
| sendJson(res, 400, { ok: false, error: "Validation failed", details: errors }); | |
| return true; | |
| } | |
| const profile = parseResult.data; | |
| // SSRF check for picture URL | |
| if (profile.picture) { | |
| const pictureCheck = validateUrlSafety(profile.picture); | |
| if (!pictureCheck.ok) { | |
| sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` }); | |
| return true; | |
| } | |
| } | |
| // SSRF check for banner URL | |
| if (profile.banner) { | |
| const bannerCheck = validateUrlSafety(profile.banner); | |
| if (!bannerCheck.ok) { | |
| sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` }); | |
| return true; | |
| } | |
| } | |
| // SSRF check for website URL | |
| if (profile.website) { | |
| const websiteCheck = validateUrlSafety(profile.website); | |
| if (!websiteCheck.ok) { | |
| sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` }); | |
| return true; | |
| } | |
| } | |
| // Merge with existing profile to preserve unknown fields | |
| const existingProfile = ctx.getConfigProfile(accountId) ?? {}; | |
| const mergedProfile: NostrProfile = { | |
| ...existingProfile, | |
| ...profile, | |
| }; | |
| // Publish with mutex to prevent concurrent publishes | |
| try { | |
| const result = await withPublishLock(accountId, async () => { | |
| return await publishNostrProfile(accountId, mergedProfile); | |
| }); | |
| // Only persist if at least one relay succeeded | |
| if (result.successes.length > 0) { | |
| await ctx.updateConfigProfile(accountId, mergedProfile); | |
| ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`); | |
| } else { | |
| ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`); | |
| } | |
| sendJson(res, 200, { | |
| ok: true, | |
| eventId: result.eventId, | |
| createdAt: result.createdAt, | |
| successes: result.successes, | |
| failures: result.failures, | |
| persisted: result.successes.length > 0, | |
| }); | |
| } catch (err) { | |
| ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`); | |
| sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` }); | |
| } | |
| return true; | |
| } | |
| // ============================================================================ | |
| // POST /api/channels/nostr/:accountId/profile/import | |
| // ============================================================================ | |
| async function handleImportProfile( | |
| accountId: string, | |
| ctx: NostrProfileHttpContext, | |
| req: IncomingMessage, | |
| res: ServerResponse, | |
| ): Promise<true> { | |
| // Get account info | |
| const accountInfo = ctx.getAccountInfo(accountId); | |
| if (!accountInfo) { | |
| sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` }); | |
| return true; | |
| } | |
| const { pubkey, relays } = accountInfo; | |
| if (!pubkey) { | |
| sendJson(res, 400, { ok: false, error: "Account has no public key configured" }); | |
| return true; | |
| } | |
| // Parse options from body | |
| let autoMerge = false; | |
| try { | |
| const body = await readJsonBody(req); | |
| if (typeof body === "object" && body !== null) { | |
| autoMerge = (body as { autoMerge?: boolean }).autoMerge === true; | |
| } | |
| } catch { | |
| // Ignore body parse errors - use defaults | |
| } | |
| ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`); | |
| // Import from relays | |
| const result = await importProfileFromRelays({ | |
| pubkey, | |
| relays, | |
| timeoutMs: 10_000, // 10 seconds for import | |
| }); | |
| if (!result.ok) { | |
| sendJson(res, 200, { | |
| ok: false, | |
| error: result.error, | |
| relaysQueried: result.relaysQueried, | |
| }); | |
| return true; | |
| } | |
| // If autoMerge is requested, merge and save | |
| if (autoMerge && result.profile) { | |
| const localProfile = ctx.getConfigProfile(accountId); | |
| const merged = mergeProfiles(localProfile, result.profile); | |
| await ctx.updateConfigProfile(accountId, merged); | |
| ctx.log?.info(`[${accountId}] Profile imported and merged`); | |
| sendJson(res, 200, { | |
| ok: true, | |
| imported: result.profile, | |
| merged, | |
| saved: true, | |
| event: result.event, | |
| sourceRelay: result.sourceRelay, | |
| relaysQueried: result.relaysQueried, | |
| }); | |
| return true; | |
| } | |
| // Otherwise, just return the imported profile for review | |
| sendJson(res, 200, { | |
| ok: true, | |
| imported: result.profile, | |
| saved: false, | |
| event: result.event, | |
| sourceRelay: result.sourceRelay, | |
| relaysQueried: result.relaysQueried, | |
| }); | |
| return true; | |
| } | |