/** * 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 "openclaw/plugin-sdk/zod"; import { publishNostrProfile, getNostrProfileState } from "./channel.js"; import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; import {
createFixedWindowRateLimiter,
getPluginRuntimeGatewayRequestScope,
readJsonBodyWithLimit,
requestBodyErrorToText,
} from "./nostr-profile-http-runtime.js"; import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; import { validateUrlSafety } from "./nostr-profile-url-safety.js";
function checkRateLimit(accountId: string): boolean { return !profileRateLimiter.isRateLimited(accountId);
}
// ============================================================================ // 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);
}
}
}
// Export for use in import validation
export { validateUrlSafety };
function hasNonLoopbackForwardedClient(req: IncomingMessage): boolean { const forwardedFor = firstHeaderValue(req.headers["x-forwarded-for"]); if (forwardedFor) { for (const hop of forwardedFor.split(",")) { const candidate = normalizeIpCandidate(hop); if (!candidate) { continue;
} if (!isLoopbackRemoteAddress(candidate)) { returntrue;
}
}
}
const realIp = firstHeaderValue(req.headers["x-real-ip"]); if (realIp) { const candidate = normalizeIpCandidate(realIp); if (candidate && !isLoopbackRemoteAddress(candidate)) { returntrue;
}
}
returnfalse;
}
function enforceLoopbackMutationGuards(
ctx: NostrProfileHttpContext,
req: IncomingMessage,
res: ServerResponse,
): boolean { // Mutation endpoints are local-control-plane only. const remoteAddress = req.socket.remoteAddress; if (!isLoopbackRemoteAddress(remoteAddress)) {
ctx.log?.warn?.(`Rejected mutation from non-loopback remoteAddress=${String(remoteAddress)}`);
sendJson(res, 403, { ok: false, error: "Forbidden" }); returnfalse;
}
// If a proxy exposes client-origin headers showing a non-loopback client, // treat this as a remote request and deny mutation. if (hasNonLoopbackForwardedClient(req)) {
ctx.log?.warn?.("Rejected mutation with non-loopback forwarded client headers");
sendJson(res, 403, { ok: false, error: "Forbidden" }); returnfalse;
}
// 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`);
}
// ============================================================================ // POST /api/channels/nostr/:accountId/profile/import // ============================================================================
async function handleImportProfile(
accountId: string,
ctx: NostrProfileHttpContext,
req: IncomingMessage,
res: ServerResponse,
): Promise<true> { if (!enforceGatewayMutationScope(ctx, accountId, res)) { returntrue;
} if (!enforceLoopbackMutationGuards(ctx, req, res)) { returntrue;
}
// Get account info const accountInfo = ctx.getAccountInfo(accountId); if (!accountInfo) {
sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` }); returntrue;
}
const { pubkey, relays } = accountInfo;
if (!pubkey) {
sendJson(res, 400, { ok: false, error: "Account has no public key configured" }); returntrue;
}
// 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 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`);
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.