From cce9cf69734e6df3352dd789d21b4a48ae758aca Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:48:59 -0400 Subject: [PATCH] Refactor nostr publish helpers into shared module --- js/nostr.js | 931 +---------------------- js/nostr/publishHelpers.js | 981 +++++++++++++++++++++++++ tests/nostr-boost-actions.test.mjs | 227 ++++-- tests/nostr-rebroadcast-guard.test.mjs | 44 +- 4 files changed, 1216 insertions(+), 967 deletions(-) create mode 100644 js/nostr/publishHelpers.js diff --git a/js/nostr.js b/js/nostr.js index ee73b65a..a6457a67 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1,6 +1,6 @@ // js/nostr.js -import { isDevMode, ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "./config.js"; +import { isDevMode } from "./config.js"; import { FEATURE_PUBLISH_NIP71 } from "./constants.js"; import { accessControl } from "./accessControl.js"; import { @@ -60,8 +60,15 @@ import { publishEventToRelay, publishEventToRelays, assertAnyRelayAccepted, - summarizePublishResults, } from "./nostrPublish.js"; +import { + signEventWithPrivateKey, + signAndPublishEvent as signAndPublishEventHelper, + repostEvent as repostEventHelper, + mirrorVideoEvent as mirrorVideoEventHelper, + rebroadcastEvent as rebroadcastEventHelper, + summarizePublishResults, +} from "./nostr/publishHelpers.js"; import { DEFAULT_RELAY_URLS, RELAY_URLS, @@ -182,170 +189,6 @@ const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents"; const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes -const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; - - -function deriveRebroadcastBucketIndex(referenceSeconds = null) { - const windowSeconds = Math.max( - 1, - Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0 - ); - const baseSeconds = Number.isFinite(referenceSeconds) - ? Math.max(0, Math.floor(referenceSeconds)) - : Math.floor(Date.now() / 1000); - return Math.floor(baseSeconds / windowSeconds); -} - -function getRebroadcastCooldownWindowMs() { - const windowSeconds = Math.max( - 1, - Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0 - ); - return windowSeconds * 1000; -} - -function deriveRebroadcastScope(pubkey, eventId) { - const normalizedPubkey = - typeof pubkey === "string" && pubkey.trim() - ? pubkey.trim().toLowerCase() - : ""; - const normalizedEventId = - typeof eventId === "string" && eventId.trim() - ? eventId.trim().toLowerCase() - : ""; - if (!normalizedPubkey || !normalizedEventId) { - return ""; - } - return `${normalizedPubkey}:${normalizedEventId}`; -} - -function readRebroadcastGuardEntry(scope) { - if (!scope) { - return null; - } - - const windowMs = getRebroadcastCooldownWindowMs(); - const now = Date.now(); - const entry = rebroadcastAttemptMemory.get(scope); - - if (entry) { - const age = now - Number(entry.seenAt); - if (!Number.isFinite(entry.seenAt) || age >= windowMs) { - rebroadcastAttemptMemory.delete(scope); - } else { - return entry; - } - } - - if (typeof localStorage === "undefined") { - return null; - } - - const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`; - let rawValue = null; - try { - rawValue = localStorage.getItem(storageKey); - } catch (error) { - devLogger.warn("[nostr] Failed to read rebroadcast guard entry:", error); - return null; - } - - if (typeof rawValue !== "string" || !rawValue) { - return null; - } - - const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2); - const storedBucket = Number(storedBucketRaw); - const storedSeenAt = Number(storedSeenRaw); - - if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) { - try { - localStorage.removeItem(storageKey); - } catch (error) { - devLogger.warn( - "[nostr] Failed to clear corrupt rebroadcast guard entry:", - error - ); - } - return null; - } - - if (now - storedSeenAt >= windowMs) { - try { - localStorage.removeItem(storageKey); - } catch (error) { - devLogger.warn("[nostr] Failed to remove expired rebroadcast guard entry:", error); - } - return null; - } - - const normalizedEntry = { - bucket: storedBucket, - seenAt: storedSeenAt, - }; - rebroadcastAttemptMemory.set(scope, normalizedEntry); - return normalizedEntry; -} - -function hasRecentRebroadcastAttempt(scope, bucketIndex) { - if (!scope || !Number.isFinite(bucketIndex)) { - return false; - } - - const entry = readRebroadcastGuardEntry(scope); - return entry ? Number(entry.bucket) === bucketIndex : false; -} - -function rememberRebroadcastAttempt(scope, bucketIndex) { - if (!scope || !Number.isFinite(bucketIndex)) { - return; - } - - const now = Date.now(); - const windowMs = getRebroadcastCooldownWindowMs(); - const entry = rebroadcastAttemptMemory.get(scope); - if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) { - rebroadcastAttemptMemory.delete(scope); - } - - const normalizedEntry = { bucket: bucketIndex, seenAt: now }; - rebroadcastAttemptMemory.set(scope, normalizedEntry); - - if (typeof localStorage === "undefined") { - return; - } - - const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`; - try { - localStorage.setItem(storageKey, `${bucketIndex}:${now}`); - } catch (error) { - devLogger.warn("[nostr] Failed to persist rebroadcast guard entry:", error); - } -} - -function getRebroadcastCooldownState(scope) { - if (!scope) { - return null; - } - const entry = readRebroadcastGuardEntry(scope); - if (!entry || !Number.isFinite(entry.seenAt)) { - return null; - } - const windowMs = getRebroadcastCooldownWindowMs(); - const expiresAt = entry.seenAt + windowMs; - const remainingMs = Math.max(0, expiresAt - Date.now()); - if (remainingMs <= 0) { - return null; - } - return { - scope, - seenAt: entry.seenAt, - bucket: entry.bucket, - expiresAt, - remainingMs, - }; -} - // To limit error spam let errorLogCount = 0; const MAX_ERROR_LOGS = 100; @@ -478,34 +321,6 @@ function eventToAddressPointer(event) { return ""; } -function signEventWithPrivateKey(event, privateKey) { - const tools = getCachedNostrTools(); - if ( - !privateKey || - typeof privateKey !== "string" || - !tools?.getEventHash || - typeof tools.signEvent !== "function" - ) { - throw new Error("Missing signing primitives"); - } - - const tags = Array.isArray(event.tags) - ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) - : []; - - const prepared = { - kind: event.kind, - pubkey: event.pubkey, - created_at: event.created_at, - tags, - content: typeof event.content === "string" ? event.content : "", - }; - const id = tools.getEventHash(prepared); - const sig = tools.signEvent(prepared, privateKey); - - return { ...prepared, id, sig }; -} - let loggedMissingCipherToolkit = false; let loggedMissingNip04Cipher = false; let loggedMissingNip44Cipher = false; @@ -3411,177 +3226,15 @@ export class NostrClient { /** * Publish a new video using the v3 content schema. */ - async signAndPublishEvent( - event, - { - context = "event", - logName = context, - devLogLabel = logName, - rejectionLogLevel = "error", - relaysOverride = null, - } = {} - ) { - const normalizedEventPubkey = - event && typeof event.pubkey === "string" - ? event.pubkey.toLowerCase() - : ""; - const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - const usingSessionActor = - normalizedEventPubkey && - normalizedLogged && - normalizedEventPubkey !== normalizedLogged; - - const signer = resolveActiveSigner(normalizedEventPubkey || this.pubkey); - const canUseActiveSigner = - !usingSessionActor && - signer && - typeof signer.signEvent === "function"; - - let eventToSign = event; - let signedEvent = null; - let signerPubkey = null; - - if (canUseActiveSigner) { - let permissionResult = { ok: true }; - if (shouldRequestExtensionPermissions(signer)) { - permissionResult = await this.ensureExtensionPermissions( - DEFAULT_NIP07_PERMISSION_METHODS, - ); - } - if (permissionResult.ok) { - try { - signedEvent = await signer.signEvent(event); - } catch (error) { - userLogger.warn( - "[nostr] Failed to sign event with active signer:", - error, - ); - } - } else { - userLogger.warn( - "[nostr] Active signer permissions missing; falling back to session signer.", - permissionResult.error, - ); - } - } - - if (!signedEvent) { - try { - const currentSessionPubkey = - typeof this.sessionActor?.pubkey === "string" - ? this.sessionActor.pubkey.toLowerCase() - : ""; - - if ( - usingSessionActor && - normalizedEventPubkey && - normalizedEventPubkey !== currentSessionPubkey - ) { - await this.ensureSessionActor(true); - } else { - await this.ensureSessionActor(); - } - - const sessionActor = this.sessionActor; - if ( - !sessionActor || - typeof sessionActor.pubkey !== "string" || - !sessionActor.pubkey || - typeof sessionActor.privateKey !== "string" || - !sessionActor.privateKey - ) { - throw new Error("session-actor-unavailable"); - } - - const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase(); - if ( - !normalizedEventPubkey || - normalizedEventPubkey !== normalizedSessionPubkey || - event.pubkey !== sessionActor.pubkey - ) { - eventToSign = { ...event, pubkey: sessionActor.pubkey }; - } - - signedEvent = signEventWithPrivateKey( - eventToSign, - sessionActor.privateKey - ); - signerPubkey = sessionActor.pubkey; - } catch (error) { - userLogger.warn("[nostr] Failed to sign event with session key:", error); - throw error; - } - } - - if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") { - signerPubkey = signedEvent.pubkey; - } - - devLogger.log(`Signed ${devLogLabel} event:`, signedEvent); - - let targetRelays = sanitizeRelayList( - Array.isArray(relaysOverride) && relaysOverride.length - ? relaysOverride - : Array.isArray(this.writeRelays) && this.writeRelays.length - ? this.writeRelays - : this.relays - ); - - if (!targetRelays.length) { - targetRelays = Array.from(RELAY_URLS); - } - - const publishResults = await publishEventToRelays( - this.pool, - targetRelays, - signedEvent - ); - - let publishSummary; - try { - publishSummary = assertAnyRelayAccepted(publishResults, { context }); - } catch (publishError) { - if (publishError?.relayFailures?.length) { - const logLevel = rejectionLogLevel === "warn" ? "warn" : "error"; - publishError.relayFailures.forEach( - ({ url, error: relayError, reason }) => { - const logFn = logLevel === "warn" ? userLogger.warn : userLogger.error; - logFn( - `[nostr] ${logName} rejected by ${url}: ${reason}`, - relayError || reason - ); - } - ); - } - throw publishError; - } - - publishSummary.accepted.forEach(({ url }) => { - devLogger.log(`${logName} published to ${url}`); + async signAndPublishEvent(event, options = {}) { + return signAndPublishEventHelper({ + client: this, + event, + options, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey, }); - - if (publishSummary.failed.length) { - publishSummary.failed.forEach(({ url, error: relayError }) => { - const reason = - relayError instanceof Error - ? relayError.message - : relayError - ? String(relayError) - : "publish failed"; - userLogger.warn( - `[nostr] ${logName} not accepted by ${url}: ${reason}`, - relayError - ); - }); - } - - return { - signedEvent, - summary: publishSummary, - signerPubkey, - relays: targetRelays, - }; } async publishVideo(videoPayload, pubkey) { @@ -5081,547 +4734,31 @@ export class NostrClient { } async repostEvent(eventId, options = {}) { - const normalizedId = - typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; - if (!normalizedId) { - return { ok: false, error: "invalid-event-id" }; - } - - let pointer = null; - if (options.pointer) { - pointer = normalizePointerInput(options.pointer); - } - if (!pointer) { - const type = - typeof options.pointerType === "string" ? options.pointerType.trim() : ""; - const value = - typeof options.pointerValue === "string" ? options.pointerValue.trim() : ""; - if (type && value) { - const candidate = [type, value]; - const relay = - typeof options.pointerRelay === "string" - ? options.pointerRelay.trim() - : ""; - if (relay) { - candidate.push(relay); - } - pointer = normalizePointerInput(candidate); - } - } - - const cachedVideo = this.allEvents.get(normalizedId) || null; - const cachedRaw = this.rawEvents.get(normalizedId) || null; - - let authorPubkey = - typeof options.authorPubkey === "string" && options.authorPubkey.trim() - ? options.authorPubkey.trim().toLowerCase() - : ""; - - if (!authorPubkey && cachedVideo?.pubkey) { - authorPubkey = cachedVideo.pubkey.trim().toLowerCase(); - } - if (!authorPubkey && cachedRaw?.pubkey) { - authorPubkey = cachedRaw.pubkey.trim().toLowerCase(); - } - - let address = typeof options.address === "string" ? options.address.trim() : ""; - if (!address && pointer?.type === "a") { - address = pointer.value; - } - - let addressRelay = - typeof options.addressRelay === "string" ? options.addressRelay.trim() : ""; - if (!addressRelay && pointer?.type === "a" && pointer.relay) { - addressRelay = pointer.relay; - } - - let eventRelay = - typeof options.eventRelay === "string" ? options.eventRelay.trim() : ""; - if (!eventRelay && pointer?.type === "e" && pointer.relay) { - eventRelay = pointer.relay; - } - - let targetKind = Number.isFinite(options.kind) - ? Math.floor(options.kind) - : null; - - const parseAddressMetadata = (candidate) => { - if (typeof candidate !== "string" || !candidate) { - return; - } - const parts = candidate.split(":"); - if (parts.length >= 3) { - const maybeKind = Number.parseInt(parts[0], 10); - if (Number.isFinite(maybeKind) && !Number.isFinite(targetKind)) { - targetKind = maybeKind; - } - const maybePubkey = parts[1]; - if ( - maybePubkey && - !authorPubkey && - /^[0-9a-f]{64}$/i.test(maybePubkey) - ) { - authorPubkey = maybePubkey.toLowerCase(); - } - } - }; - - if (address) { - parseAddressMetadata(address); - } - - if (!Number.isFinite(targetKind)) { - if (Number.isFinite(cachedRaw?.kind)) { - targetKind = Math.floor(cachedRaw.kind); - } else if (Number.isFinite(cachedVideo?.kind)) { - targetKind = Math.floor(cachedVideo.kind); - } else { - targetKind = 30078; - } - } - - const deriveIdentifierFromVideo = () => { - if (!cachedVideo || typeof cachedVideo !== "object") { - return ""; - } - - if (typeof cachedVideo.videoRootId === "string" && cachedVideo.videoRootId.trim()) { - return cachedVideo.videoRootId.trim(); - } - - if (Array.isArray(cachedVideo.tags)) { - for (const tag of cachedVideo.tags) { - if (!Array.isArray(tag) || tag.length < 2) { - continue; - } - if (tag[0] === "d" && typeof tag[1] === "string" && tag[1].trim()) { - return tag[1].trim(); - } - } - } - - return ""; - }; - - if (!address) { - const identifier = deriveIdentifierFromVideo(); - const ownerPubkey = - authorPubkey || - (cachedVideo?.pubkey ? cachedVideo.pubkey.trim().toLowerCase() : "") || - (cachedRaw?.pubkey ? cachedRaw.pubkey.trim().toLowerCase() : ""); - - if (identifier && ownerPubkey) { - address = `${targetKind}:${ownerPubkey}:${identifier}`; - parseAddressMetadata(address); - } else if (cachedRaw) { - const fallbackAddress = eventToAddressPointer(cachedRaw); - if (fallbackAddress) { - address = fallbackAddress; - parseAddressMetadata(address); - } - } - } - - const relaysOverride = sanitizeRelayList( - Array.isArray(options.relays) && options.relays.length - ? options.relays - : Array.isArray(this.writeRelays) && this.writeRelays.length - ? this.writeRelays - : this.relays - ); - const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS); - - let actorPubkey = - typeof options.actorPubkey === "string" && options.actorPubkey.trim() - ? options.actorPubkey.trim() - : typeof this.pubkey === "string" && this.pubkey.trim() - ? this.pubkey.trim() - : ""; - - if (!actorPubkey) { - try { - const ensured = await this.ensureSessionActor(); - actorPubkey = ensured || ""; - } catch (error) { - devLogger.warn("[nostr] Failed to ensure session actor before repost:", error); - return { ok: false, error: "missing-actor", details: error }; - } - } - - if (!actorPubkey) { - return { ok: false, error: "missing-actor" }; - } - - if (!this.pool) { - try { - await this.ensurePool(); - } catch (error) { - devLogger.warn("[nostr] Failed to ensure pool before repost:", error); - return { ok: false, error: "pool-unavailable", details: error }; - } - } - - const createdAt = - typeof options.created_at === "number" && Number.isFinite(options.created_at) - ? Math.max(0, Math.floor(options.created_at)) - : Math.floor(Date.now() / 1000); - - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) - : []; - - const repostEvent = buildRepostEvent({ - pubkey: actorPubkey, - created_at: createdAt, - eventId: normalizedId, - eventRelay, - address, - addressRelay, - authorPubkey, - additionalTags, + return repostEventHelper({ + client: this, + eventId, + options, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey, + eventToAddressPointer, }); - - try { - const { signedEvent, summary, signerPubkey } = await this.signAndPublishEvent( - repostEvent, - { - context: "repost", - logName: "Repost", - devLogLabel: "repost", - relaysOverride: relays, - } - ); - - const normalizedSigner = - typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : ""; - const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - const sessionPubkey = - typeof this.sessionActor?.pubkey === "string" - ? this.sessionActor.pubkey.toLowerCase() - : ""; - - const usedSessionActor = - normalizedSigner && - normalizedSigner !== normalizedLogged && - normalizedSigner === sessionPubkey; - - return { - ok: true, - event: signedEvent, - summary, - relays, - sessionActor: usedSessionActor, - signerPubkey, - }; - } catch (error) { - devLogger.warn("[nostr] Repost publish failed:", error); - const relayFailure = - error && typeof error === "object" && Array.isArray(error.relayFailures); - return { - ok: false, - error: relayFailure ? "publish-rejected" : "signing-failed", - details: error, - }; - } } async mirrorVideoEvent(eventId, options = {}) { - const normalizedId = - typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; - if (!normalizedId) { - return { ok: false, error: "invalid-event-id" }; - } - - const cachedVideo = this.allEvents.get(normalizedId) || null; - - const sanitize = (value) => (typeof value === "string" ? value.trim() : ""); - - let url = sanitize(options.url); - if (!url && cachedVideo?.url) { - url = sanitize(cachedVideo.url); - } - - if (!url) { - return { ok: false, error: "missing-url" }; - } - - const isPrivate = - options.isPrivate === true || - options.isPrivate === "true" || - cachedVideo?.isPrivate === true; - - let magnet = sanitize(options.magnet); - if (!magnet && cachedVideo?.magnet) { - magnet = sanitize(cachedVideo.magnet); - } - if (!magnet && cachedVideo?.rawMagnet) { - magnet = sanitize(cachedVideo.rawMagnet); - } - if (!magnet && cachedVideo?.originalMagnet) { - magnet = sanitize(cachedVideo.originalMagnet); - } - - let thumbnail = sanitize(options.thumbnail); - if (!thumbnail && cachedVideo?.thumbnail) { - thumbnail = sanitize(cachedVideo.thumbnail); - } - - let description = sanitize(options.description); - if (!description && cachedVideo?.description) { - description = sanitize(cachedVideo.description); - } - - let title = sanitize(options.title); - if (!title && cachedVideo?.title) { - title = sanitize(cachedVideo.title); - } - - const providedMimeType = sanitize(options.mimeType); - const inferredMimeType = inferMimeTypeFromUrl(url); - const mimeType = providedMimeType || inferredMimeType || "application/octet-stream"; - - const explicitAlt = sanitize(options.altText); - const altText = explicitAlt || description || title || ""; - - const tags = []; - tags.push(["url", url]); - if (mimeType) { - tags.push(["m", mimeType]); - } - if (thumbnail) { - tags.push(["thumb", thumbnail]); - } - if (altText) { - tags.push(["alt", altText]); - } - if (!isPrivate && magnet) { - tags.push(["magnet", magnet]); - } - - const additionalTags = Array.isArray(options.additionalTags) - ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) - : []; - tags.push(...additionalTags); - - let actorPubkey = - typeof options.actorPubkey === "string" && options.actorPubkey.trim() - ? options.actorPubkey.trim() - : typeof this.pubkey === "string" && this.pubkey.trim() - ? this.pubkey.trim() - : ""; - - if (!actorPubkey) { - try { - const ensured = await this.ensureSessionActor(); - actorPubkey = ensured || ""; - } catch (error) { - devLogger.warn("[nostr] Failed to ensure session actor before mirror:", error); - return { ok: false, error: "missing-actor", details: error }; - } - } - - if (!actorPubkey) { - return { ok: false, error: "missing-actor" }; - } - - if (!this.pool) { - try { - await this.ensurePool(); - } catch (error) { - devLogger.warn("[nostr] Failed to ensure pool before mirror:", error); - return { ok: false, error: "pool-unavailable", details: error }; - } - } - - const createdAt = - typeof options.created_at === "number" && Number.isFinite(options.created_at) - ? Math.max(0, Math.floor(options.created_at)) - : Math.floor(Date.now() / 1000); - - const relaysOverride = sanitizeRelayList( - Array.isArray(options.relays) && options.relays.length - ? options.relays - : Array.isArray(this.writeRelays) && this.writeRelays.length - ? this.writeRelays - : this.relays - ); - const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS); - - const mirrorEvent = buildVideoMirrorEvent({ - pubkey: actorPubkey, - created_at: createdAt, - tags, - content: altText, + return mirrorVideoEventHelper({ + client: this, + eventId, + options, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey, + inferMimeTypeFromUrl, }); - - try { - const { signedEvent, summary, signerPubkey } = await this.signAndPublishEvent( - mirrorEvent, - { - context: "mirror", - logName: "NIP-94 mirror", - devLogLabel: "NIP-94 mirror", - rejectionLogLevel: "warn", - relaysOverride: relays, - } - ); - - const normalizedSigner = - typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : ""; - const normalizedLogged = - typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : ""; - const sessionPubkey = - typeof this.sessionActor?.pubkey === "string" - ? this.sessionActor.pubkey.toLowerCase() - : ""; - - const usedSessionActor = - normalizedSigner && - normalizedSigner !== normalizedLogged && - normalizedSigner === sessionPubkey; - - return { - ok: true, - event: signedEvent, - summary, - relays, - sessionActor: usedSessionActor, - signerPubkey, - }; - } catch (error) { - devLogger.warn("[nostr] Mirror publish failed:", error); - const relayFailure = - error && typeof error === "object" && Array.isArray(error.relayFailures); - return { - ok: false, - error: relayFailure ? "publish-rejected" : "signing-failed", - details: error, - }; - } } async rebroadcastEvent(eventId, options = {}) { - const normalizedId = - typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; - if (!normalizedId) { - return { ok: false, error: "invalid-event-id" }; - } - - const candidatePubkeys = []; - if (typeof options.pubkey === "string" && options.pubkey.trim()) { - candidatePubkeys.push(options.pubkey.trim().toLowerCase()); - } - const cachedVideo = this.allEvents.get(normalizedId); - if (cachedVideo?.pubkey) { - candidatePubkeys.push(cachedVideo.pubkey.toLowerCase()); - } - const cachedRaw = this.rawEvents.get(normalizedId); - if (cachedRaw?.pubkey) { - candidatePubkeys.push(cachedRaw.pubkey.toLowerCase()); - } - - let normalizedPubkey = ""; - for (const candidate of candidatePubkeys) { - if (typeof candidate === "string" && candidate) { - normalizedPubkey = candidate; - break; - } - } - - let guardScope = deriveRebroadcastScope(normalizedPubkey, normalizedId); - const guardBucket = deriveRebroadcastBucketIndex(); - if (guardScope && hasRecentRebroadcastAttempt(guardScope, guardBucket)) { - const cooldown = getRebroadcastCooldownState(guardScope); - return { ok: false, error: "cooldown-active", throttled: true, cooldown }; - } - - const relayCandidates = Array.isArray(options.relays) && options.relays.length - ? options.relays - : Array.isArray(this.relays) && this.relays.length - ? this.relays - : RELAY_URLS; - const relays = relayCandidates - .map((url) => (typeof url === "string" ? url.trim() : "")) - .filter(Boolean); - - if (!this.pool) { - try { - await this.ensurePool(); - } catch (error) { - devLogger.warn("[nostr] Failed to ensure pool before rebroadcast:", error); - return { ok: false, error: "pool-unavailable", details: error }; - } - } - - let rawEvent = - options.rawEvent || cachedRaw || (await this.fetchRawEventById(normalizedId, { relays })); - - if (!rawEvent) { - return { ok: false, error: "event-not-found" }; - } - - if (!normalizedPubkey && typeof rawEvent.pubkey === "string") { - normalizedPubkey = rawEvent.pubkey.trim().toLowerCase(); - } - - const effectiveScope = deriveRebroadcastScope(normalizedPubkey, normalizedId); - if (effectiveScope && !guardScope) { - guardScope = effectiveScope; - if (hasRecentRebroadcastAttempt(guardScope, guardBucket)) { - const cooldown = getRebroadcastCooldownState(guardScope); - return { ok: false, error: "cooldown-active", throttled: true, cooldown }; - } - } - - if (guardScope) { - rememberRebroadcastAttempt(guardScope, guardBucket); - } - - let countResult = null; - if (options.skipCount !== true) { - try { - countResult = await this.countEventsAcrossRelays([ - { ids: [normalizedId] }, - ], { - relays, - timeoutMs: options.timeoutMs, - }); - } catch (error) { - devLogger.warn("[nostr] COUNT request for rebroadcast failed:", error); - } - - if (countResult?.total && Number(countResult.total) > 0) { - return { - ok: true, - alreadyPresent: true, - count: countResult, - cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, - }; - } - } - - const publishResults = await publishEventToRelays(this.pool, relays, rawEvent); - - try { - const summary = assertAnyRelayAccepted(publishResults, { context: "rebroadcast" }); - return { - ok: true, - rebroadcast: true, - summary, - count: countResult, - cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, - }; - } catch (error) { - devLogger.warn("[nostr] Rebroadcast rejected by relays:", error); - return { - ok: false, - error: "publish-rejected", - details: error, - results: publishResults, - cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, - }; - } + return rebroadcastEventHelper({ client: this, eventId, options }); } getRequestTimeoutMs(timeoutMs) { diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js new file mode 100644 index 00000000..8a355b7c --- /dev/null +++ b/js/nostr/publishHelpers.js @@ -0,0 +1,981 @@ +import { + ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, +} from "../config.js"; +import { + publishEventToRelays, + assertAnyRelayAccepted, + summarizePublishResults, +} from "../nostrPublish.js"; +import { buildRepostEvent, buildVideoMirrorEvent } from "../nostrEventSchemas.js"; +import { normalizePointerInput } from "./watchHistory.js"; +import { sanitizeRelayList } from "./nip46Client.js"; +import { RELAY_URLS, getCachedNostrTools } from "./toolkit.js"; +import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; +const rebroadcastAttemptMemory = new Map(); + +export function signEventWithPrivateKey(event, privateKey) { + const tools = getCachedNostrTools(); + if ( + !privateKey || + typeof privateKey !== "string" || + !tools?.getEventHash || + typeof tools.signEvent !== "function" + ) { + throw new Error("Missing signing primitives"); + } + + const tags = Array.isArray(event.tags) + ? event.tags.map((tag) => (Array.isArray(tag) ? [...tag] : tag)) + : []; + + const prepared = { + kind: event.kind, + pubkey: event.pubkey, + created_at: event.created_at, + tags, + content: typeof event.content === "string" ? event.content : "", + }; + const id = tools.getEventHash(prepared); + const sig = tools.signEvent(prepared, privateKey); + + return { ...prepared, id, sig }; +} + +export function deriveRebroadcastBucketIndex(referenceSeconds = null) { + const windowSeconds = Math.max( + 1, + Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0, + ); + const baseSeconds = Number.isFinite(referenceSeconds) + ? Math.max(0, Math.floor(referenceSeconds)) + : Math.floor(Date.now() / 1000); + return Math.floor(baseSeconds / windowSeconds); +} + +export function getRebroadcastCooldownWindowMs() { + const windowSeconds = Math.max( + 1, + Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) || 0, + ); + return windowSeconds * 1000; +} + +export function deriveRebroadcastScope(pubkey, eventId) { + const normalizedPubkey = + typeof pubkey === "string" && pubkey.trim() + ? pubkey.trim().toLowerCase() + : ""; + const normalizedEventId = + typeof eventId === "string" && eventId.trim() + ? eventId.trim().toLowerCase() + : ""; + if (!normalizedPubkey || !normalizedEventId) { + return ""; + } + return `${normalizedPubkey}:${normalizedEventId}`; +} + +function readRebroadcastGuardEntry(scope) { + if (!scope) { + return null; + } + + const windowMs = getRebroadcastCooldownWindowMs(); + const now = Date.now(); + const entry = rebroadcastAttemptMemory.get(scope); + + if (entry) { + const age = now - Number(entry.seenAt); + if (!Number.isFinite(entry.seenAt) || age >= windowMs) { + rebroadcastAttemptMemory.delete(scope); + } else { + return entry; + } + } + + if (typeof localStorage === "undefined") { + return null; + } + + const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`; + let rawValue = null; + try { + rawValue = localStorage.getItem(storageKey); + } catch (error) { + devLogger.warn("[nostr] Failed to read rebroadcast guard entry:", error); + return null; + } + + if (typeof rawValue !== "string" || !rawValue) { + return null; + } + + const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2); + const storedBucket = Number(storedBucketRaw); + const storedSeenAt = Number(storedSeenRaw); + + if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) { + try { + localStorage.removeItem(storageKey); + } catch (error) { + devLogger.warn( + "[nostr] Failed to clear corrupt rebroadcast guard entry:", + error, + ); + } + return null; + } + + if (now - storedSeenAt >= windowMs) { + try { + localStorage.removeItem(storageKey); + } catch (error) { + devLogger.warn( + "[nostr] Failed to remove expired rebroadcast guard entry:", + error, + ); + } + return null; + } + + const normalizedEntry = { + bucket: storedBucket, + seenAt: storedSeenAt, + }; + rebroadcastAttemptMemory.set(scope, normalizedEntry); + return normalizedEntry; +} + +export function hasRecentRebroadcastAttempt(scope, bucketIndex) { + if (!scope || !Number.isFinite(bucketIndex)) { + return false; + } + + const entry = readRebroadcastGuardEntry(scope); + return entry ? Number(entry.bucket) === bucketIndex : false; +} + +export function rememberRebroadcastAttempt(scope, bucketIndex) { + if (!scope || !Number.isFinite(bucketIndex)) { + return; + } + + const now = Date.now(); + const windowMs = getRebroadcastCooldownWindowMs(); + const entry = rebroadcastAttemptMemory.get(scope); + if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) { + rebroadcastAttemptMemory.delete(scope); + } + + const normalizedEntry = { bucket: bucketIndex, seenAt: now }; + rebroadcastAttemptMemory.set(scope, normalizedEntry); + + if (typeof localStorage === "undefined") { + return; + } + + const storageKey = `${REBROADCAST_GUARD_PREFIX}:${scope}`; + try { + localStorage.setItem(storageKey, `${bucketIndex}:${now}`); + } catch (error) { + devLogger.warn("[nostr] Failed to persist rebroadcast guard entry:", error); + } +} + +export function getRebroadcastCooldownState(scope) { + if (!scope) { + return null; + } + const entry = readRebroadcastGuardEntry(scope); + if (!entry || !Number.isFinite(entry.seenAt)) { + return null; + } + const windowMs = getRebroadcastCooldownWindowMs(); + const expiresAt = entry.seenAt + windowMs; + const remainingMs = Math.max(0, expiresAt - Date.now()); + if (remainingMs <= 0) { + return null; + } + return { + scope, + seenAt: entry.seenAt, + bucket: entry.bucket, + expiresAt, + remainingMs, + }; +} + +export async function signAndPublishEvent({ + client, + event, + options = {}, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey: signWithPrivateKey, +}) { + const { + context = "event", + logName = context, + devLogLabel = logName, + rejectionLogLevel = "error", + relaysOverride = null, + } = options; + + const normalizedEventPubkey = + event && typeof event.pubkey === "string" + ? event.pubkey.toLowerCase() + : ""; + const normalizedLogged = + typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : ""; + const usingSessionActor = + normalizedEventPubkey && + normalizedLogged && + normalizedEventPubkey !== normalizedLogged; + + const signer = + typeof resolveActiveSigner === "function" + ? resolveActiveSigner(normalizedEventPubkey || client?.pubkey) + : null; + const canUseActiveSigner = + !usingSessionActor && signer && typeof signer.signEvent === "function"; + + let eventToSign = event; + let signedEvent = null; + let signerPubkey = null; + + if (canUseActiveSigner) { + let permissionResult = { ok: true }; + if ( + typeof shouldRequestExtensionPermissions === "function" && + shouldRequestExtensionPermissions(signer) && + typeof client?.ensureExtensionPermissions === "function" + ) { + permissionResult = await client.ensureExtensionPermissions( + DEFAULT_NIP07_PERMISSION_METHODS, + ); + } + if (permissionResult.ok) { + try { + signedEvent = await signer.signEvent(event); + } catch (error) { + userLogger.warn( + "[nostr] Failed to sign event with active signer:", + error, + ); + } + } else { + userLogger.warn( + "[nostr] Active signer permissions missing; falling back to session signer.", + permissionResult.error, + ); + } + } + + if (!signedEvent) { + try { + const currentSessionPubkey = + typeof client?.sessionActor?.pubkey === "string" + ? client.sessionActor.pubkey.toLowerCase() + : ""; + + if ( + typeof client?.ensureSessionActor === "function" && + usingSessionActor && + normalizedEventPubkey && + normalizedEventPubkey !== currentSessionPubkey + ) { + await client.ensureSessionActor(true); + } else if (typeof client?.ensureSessionActor === "function") { + await client.ensureSessionActor(); + } + + const sessionActor = client?.sessionActor; + if ( + !sessionActor || + typeof sessionActor.pubkey !== "string" || + !sessionActor.pubkey || + typeof sessionActor.privateKey !== "string" || + !sessionActor.privateKey + ) { + throw new Error("session-actor-unavailable"); + } + + const normalizedSessionPubkey = sessionActor.pubkey.toLowerCase(); + if ( + !normalizedEventPubkey || + normalizedEventPubkey !== normalizedSessionPubkey || + event.pubkey !== sessionActor.pubkey + ) { + eventToSign = { ...event, pubkey: sessionActor.pubkey }; + } + + signedEvent = signWithPrivateKey(eventToSign, sessionActor.privateKey); + signerPubkey = sessionActor.pubkey; + } catch (error) { + userLogger.warn("[nostr] Failed to sign event with session key:", error); + throw error; + } + } + + if (!signerPubkey && signedEvent && typeof signedEvent.pubkey === "string") { + signerPubkey = signedEvent.pubkey; + } + + devLogger.log(`Signed ${devLogLabel} event:`, signedEvent); + + let targetRelays = sanitizeRelayList( + Array.isArray(relaysOverride) && relaysOverride.length + ? relaysOverride + : Array.isArray(client?.writeRelays) && client.writeRelays.length + ? client.writeRelays + : client?.relays, + ); + + if (!targetRelays.length) { + targetRelays = Array.from(RELAY_URLS); + } + + const publishResults = await publishEventToRelays( + client?.pool, + targetRelays, + signedEvent, + ); + + let publishSummary; + try { + publishSummary = assertAnyRelayAccepted(publishResults, { context }); + } catch (publishError) { + if (publishError?.relayFailures?.length) { + const logLevel = rejectionLogLevel === "warn" ? "warn" : "error"; + publishError.relayFailures.forEach(({ url, error: relayError, reason }) => { + const logFn = logLevel === "warn" ? userLogger.warn : userLogger.error; + logFn(`[nostr] ${logName} rejected by ${url}: ${reason}`, relayError || reason); + }); + } + throw publishError; + } + + publishSummary.accepted.forEach(({ url }) => { + devLogger.log(`${logName} published to ${url}`); + }); + + if (publishSummary.failed.length) { + publishSummary.failed.forEach(({ url, error: relayError }) => { + const reason = + relayError instanceof Error + ? relayError.message + : relayError + ? String(relayError) + : "publish failed"; + userLogger.warn( + `[nostr] ${logName} not accepted by ${url}: ${reason}`, + relayError, + ); + }); + } + + return { + signedEvent, + summary: publishSummary, + signerPubkey, + relays: targetRelays, + }; +} + +export async function repostEvent({ + client, + eventId, + options = {}, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey: signWithPrivateKey, + eventToAddressPointer, +}) { + const normalizedId = + typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; + if (!normalizedId) { + return { ok: false, error: "invalid-event-id" }; + } + + let pointer = null; + if (options.pointer) { + pointer = normalizePointerInput(options.pointer); + } + if (!pointer) { + const type = + typeof options.pointerType === "string" ? options.pointerType.trim() : ""; + const value = + typeof options.pointerValue === "string" ? options.pointerValue.trim() : ""; + if (type && value) { + const candidate = [type, value]; + const relay = + typeof options.pointerRelay === "string" + ? options.pointerRelay.trim() + : ""; + if (relay) { + candidate.push(relay); + } + pointer = normalizePointerInput(candidate); + } + } + + const cachedVideo = client?.allEvents?.get(normalizedId) || null; + const cachedRaw = client?.rawEvents?.get(normalizedId) || null; + + let authorPubkey = + typeof options.authorPubkey === "string" && options.authorPubkey.trim() + ? options.authorPubkey.trim().toLowerCase() + : ""; + + if (!authorPubkey && cachedVideo?.pubkey) { + authorPubkey = cachedVideo.pubkey.trim().toLowerCase(); + } + if (!authorPubkey && cachedRaw?.pubkey) { + authorPubkey = cachedRaw.pubkey.trim().toLowerCase(); + } + + let address = typeof options.address === "string" ? options.address.trim() : ""; + if (!address && pointer?.type === "a") { + address = pointer.value; + } + + let addressRelay = + typeof options.addressRelay === "string" ? options.addressRelay.trim() : ""; + if (!addressRelay && pointer?.type === "a" && pointer.relay) { + addressRelay = pointer.relay; + } + + let eventRelay = + typeof options.eventRelay === "string" ? options.eventRelay.trim() : ""; + if (!eventRelay && pointer?.type === "e" && pointer.relay) { + eventRelay = pointer.relay; + } + + let targetKind = Number.isFinite(options.kind) + ? Math.floor(options.kind) + : null; + + const parseAddressMetadata = (candidate) => { + if (typeof candidate !== "string" || !candidate) { + return; + } + const parts = candidate.split(":"); + if (parts.length >= 3) { + const maybeKind = Number.parseInt(parts[0], 10); + if (Number.isFinite(maybeKind) && !Number.isFinite(targetKind)) { + targetKind = maybeKind; + } + const maybePubkey = parts[1]; + if ( + maybePubkey && + !authorPubkey && + /^[0-9a-f]{64}$/i.test(maybePubkey) + ) { + authorPubkey = maybePubkey.toLowerCase(); + } + } + }; + + if (address) { + parseAddressMetadata(address); + } + + if (!Number.isFinite(targetKind)) { + if (Number.isFinite(cachedRaw?.kind)) { + targetKind = Math.floor(cachedRaw.kind); + } else if (Number.isFinite(cachedVideo?.kind)) { + targetKind = Math.floor(cachedVideo.kind); + } else { + targetKind = 30078; + } + } + + const deriveIdentifierFromVideo = () => { + if (!cachedVideo || typeof cachedVideo !== "object") { + return ""; + } + + if ( + typeof cachedVideo.videoRootId === "string" && + cachedVideo.videoRootId.trim() + ) { + return cachedVideo.videoRootId.trim(); + } + + if (Array.isArray(cachedVideo.tags)) { + for (const tag of cachedVideo.tags) { + if (!Array.isArray(tag) || tag.length < 2) { + continue; + } + if (tag[0] === "d" && typeof tag[1] === "string" && tag[1].trim()) { + return tag[1].trim(); + } + } + } + + return ""; + }; + + if (!address) { + const identifier = deriveIdentifierFromVideo(); + const ownerPubkey = + authorPubkey || + (cachedVideo?.pubkey ? cachedVideo.pubkey.trim().toLowerCase() : "") || + (cachedRaw?.pubkey ? cachedRaw.pubkey.trim().toLowerCase() : ""); + + if (identifier && ownerPubkey) { + address = `${targetKind}:${ownerPubkey}:${identifier}`; + parseAddressMetadata(address); + } else if (cachedRaw && typeof eventToAddressPointer === "function") { + const fallbackAddress = eventToAddressPointer(cachedRaw); + if (fallbackAddress) { + address = fallbackAddress; + parseAddressMetadata(address); + } + } + } + + const relaysOverride = sanitizeRelayList( + Array.isArray(options.relays) && options.relays.length + ? options.relays + : Array.isArray(client?.writeRelays) && client.writeRelays.length + ? client.writeRelays + : client?.relays, + ); + const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS); + + let actorPubkey = + typeof options.actorPubkey === "string" && options.actorPubkey.trim() + ? options.actorPubkey.trim() + : typeof client?.pubkey === "string" && client.pubkey.trim() + ? client.pubkey.trim() + : ""; + + if (!actorPubkey) { + try { + const ensured = + typeof client?.ensureSessionActor === "function" + ? await client.ensureSessionActor() + : null; + actorPubkey = ensured || ""; + } catch (error) { + devLogger.warn("[nostr] Failed to ensure session actor before repost:", error); + return { ok: false, error: "missing-actor", details: error }; + } + } + + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + if (!client?.pool) { + try { + if (typeof client?.ensurePool === "function") { + await client.ensurePool(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to ensure pool before repost:", error); + return { ok: false, error: "pool-unavailable", details: error }; + } + } + + const createdAt = + typeof options.created_at === "number" && Number.isFinite(options.created_at) + ? Math.max(0, Math.floor(options.created_at)) + : Math.floor(Date.now() / 1000); + + const additionalTags = Array.isArray(options.additionalTags) + ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) + : []; + + const repostEventPayload = buildRepostEvent({ + pubkey: actorPubkey, + created_at: createdAt, + eventId: normalizedId, + eventRelay, + address, + addressRelay, + authorPubkey, + additionalTags, + }); + + try { + const { signedEvent, summary, signerPubkey } = await signAndPublishEvent({ + client, + event: repostEventPayload, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey: signWithPrivateKey, + options: { + context: "repost", + logName: "Repost", + devLogLabel: "repost", + relaysOverride: relays, + }, + }); + + const normalizedSigner = + typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : ""; + const normalizedLogged = + typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : ""; + const sessionPubkey = + typeof client?.sessionActor?.pubkey === "string" + ? client.sessionActor.pubkey.toLowerCase() + : ""; + + const usedSessionActor = + normalizedSigner && + normalizedSigner !== normalizedLogged && + normalizedSigner === sessionPubkey; + + return { + ok: true, + event: signedEvent, + summary, + relays, + sessionActor: usedSessionActor, + signerPubkey, + }; + } catch (error) { + devLogger.warn("[nostr] Repost publish failed:", error); + const relayFailure = + error && typeof error === "object" && Array.isArray(error.relayFailures); + return { + ok: false, + error: relayFailure ? "publish-rejected" : "signing-failed", + details: error, + }; + } +} + +export async function mirrorVideoEvent({ + client, + eventId, + options = {}, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey: signWithPrivateKey, + inferMimeTypeFromUrl, +}) { + const normalizedId = + typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; + if (!normalizedId) { + return { ok: false, error: "invalid-event-id" }; + } + + const cachedVideo = client?.allEvents?.get(normalizedId) || null; + + const sanitize = (value) => (typeof value === "string" ? value.trim() : ""); + + let url = sanitize(options.url); + if (!url && cachedVideo?.url) { + url = sanitize(cachedVideo.url); + } + + if (!url) { + return { ok: false, error: "missing-url" }; + } + + const isPrivate = + options.isPrivate === true || + options.isPrivate === "true" || + cachedVideo?.isPrivate === true; + + let magnet = sanitize(options.magnet); + if (!magnet && cachedVideo?.magnet) { + magnet = sanitize(cachedVideo.magnet); + } + if (!magnet && cachedVideo?.rawMagnet) { + magnet = sanitize(cachedVideo.rawMagnet); + } + if (!magnet && cachedVideo?.originalMagnet) { + magnet = sanitize(cachedVideo.originalMagnet); + } + + let thumbnail = sanitize(options.thumbnail); + if (!thumbnail && cachedVideo?.thumbnail) { + thumbnail = sanitize(cachedVideo.thumbnail); + } + + let description = sanitize(options.description); + if (!description && cachedVideo?.description) { + description = sanitize(cachedVideo.description); + } + + let title = sanitize(options.title); + if (!title && cachedVideo?.title) { + title = sanitize(cachedVideo.title); + } + + const providedMimeType = sanitize(options.mimeType); + const inferredMimeType = inferMimeTypeFromUrl + ? inferMimeTypeFromUrl(url) + : ""; + const mimeType = providedMimeType || inferredMimeType || "application/octet-stream"; + + const explicitAlt = sanitize(options.altText); + const altText = explicitAlt || description || title || ""; + + const tags = []; + tags.push(["url", url]); + if (mimeType) { + tags.push(["m", mimeType]); + } + if (thumbnail) { + tags.push(["thumb", thumbnail]); + } + if (altText) { + tags.push(["alt", altText]); + } + if (!isPrivate && magnet) { + tags.push(["magnet", magnet]); + } + + const additionalTags = Array.isArray(options.additionalTags) + ? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2) + : []; + tags.push(...additionalTags); + + let actorPubkey = + typeof options.actorPubkey === "string" && options.actorPubkey.trim() + ? options.actorPubkey.trim() + : typeof client?.pubkey === "string" && client.pubkey.trim() + ? client.pubkey.trim() + : ""; + + if (!actorPubkey) { + try { + const ensured = + typeof client?.ensureSessionActor === "function" + ? await client.ensureSessionActor() + : null; + actorPubkey = ensured || ""; + } catch (error) { + devLogger.warn("[nostr] Failed to ensure session actor before mirror:", error); + return { ok: false, error: "missing-actor", details: error }; + } + } + + if (!actorPubkey) { + return { ok: false, error: "missing-actor" }; + } + + if (!client?.pool) { + try { + if (typeof client?.ensurePool === "function") { + await client.ensurePool(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to ensure pool before mirror:", error); + return { ok: false, error: "pool-unavailable", details: error }; + } + } + + const createdAt = + typeof options.created_at === "number" && Number.isFinite(options.created_at) + ? Math.max(0, Math.floor(options.created_at)) + : Math.floor(Date.now() / 1000); + + const relaysOverride = sanitizeRelayList( + Array.isArray(options.relays) && options.relays.length + ? options.relays + : Array.isArray(client?.writeRelays) && client.writeRelays.length + ? client.writeRelays + : client?.relays, + ); + const relays = relaysOverride.length ? relaysOverride : Array.from(RELAY_URLS); + + const mirrorEventPayload = buildVideoMirrorEvent({ + pubkey: actorPubkey, + created_at: createdAt, + tags, + content: altText, + }); + + try { + const { signedEvent, summary, signerPubkey } = await signAndPublishEvent({ + client, + event: mirrorEventPayload, + resolveActiveSigner, + shouldRequestExtensionPermissions, + signEventWithPrivateKey: signWithPrivateKey, + options: { + context: "mirror", + logName: "NIP-94 mirror", + devLogLabel: "NIP-94 mirror", + rejectionLogLevel: "warn", + relaysOverride: relays, + }, + }); + + const normalizedSigner = + typeof signerPubkey === "string" ? signerPubkey.toLowerCase() : ""; + const normalizedLogged = + typeof client?.pubkey === "string" ? client.pubkey.toLowerCase() : ""; + const sessionPubkey = + typeof client?.sessionActor?.pubkey === "string" + ? client.sessionActor.pubkey.toLowerCase() + : ""; + + const usedSessionActor = + normalizedSigner && + normalizedSigner !== normalizedLogged && + normalizedSigner === sessionPubkey; + + return { + ok: true, + event: signedEvent, + summary, + relays, + sessionActor: usedSessionActor, + signerPubkey, + }; + } catch (error) { + devLogger.warn("[nostr] Mirror publish failed:", error); + const relayFailure = + error && typeof error === "object" && Array.isArray(error.relayFailures); + return { + ok: false, + error: relayFailure ? "publish-rejected" : "signing-failed", + details: error, + }; + } +} + +export async function rebroadcastEvent({ client, eventId, options = {} }) { + const normalizedId = + typeof eventId === "string" && eventId.trim() ? eventId.trim() : ""; + if (!normalizedId) { + return { ok: false, error: "invalid-event-id" }; + } + + const candidatePubkeys = []; + if (typeof options.pubkey === "string" && options.pubkey.trim()) { + candidatePubkeys.push(options.pubkey.trim().toLowerCase()); + } + const cachedVideo = client?.allEvents?.get(normalizedId); + if (cachedVideo?.pubkey) { + candidatePubkeys.push(cachedVideo.pubkey.toLowerCase()); + } + const cachedRaw = client?.rawEvents?.get(normalizedId); + if (cachedRaw?.pubkey) { + candidatePubkeys.push(cachedRaw.pubkey.toLowerCase()); + } + + let normalizedPubkey = ""; + for (const candidate of candidatePubkeys) { + if (typeof candidate === "string" && candidate) { + normalizedPubkey = candidate; + break; + } + } + + let guardScope = deriveRebroadcastScope(normalizedPubkey, normalizedId); + const guardBucket = deriveRebroadcastBucketIndex(); + if (guardScope && hasRecentRebroadcastAttempt(guardScope, guardBucket)) { + const cooldown = getRebroadcastCooldownState(guardScope); + return { ok: false, error: "cooldown-active", throttled: true, cooldown }; + } + + const relayCandidates = + Array.isArray(options.relays) && options.relays.length + ? options.relays + : Array.isArray(client?.relays) && client.relays.length + ? client.relays + : RELAY_URLS; + const relays = relayCandidates + .map((url) => (typeof url === "string" ? url.trim() : "")) + .filter(Boolean); + + if (!client?.pool) { + try { + if (typeof client?.ensurePool === "function") { + await client.ensurePool(); + } + } catch (error) { + devLogger.warn("[nostr] Failed to ensure pool before rebroadcast:", error); + return { ok: false, error: "pool-unavailable", details: error }; + } + } + + let rawEvent = + options.rawEvent || + cachedRaw || + (typeof client?.fetchRawEventById === "function" + ? await client.fetchRawEventById(normalizedId, { relays }) + : null); + + if (!rawEvent) { + return { ok: false, error: "event-not-found" }; + } + + if (!normalizedPubkey && typeof rawEvent.pubkey === "string") { + normalizedPubkey = rawEvent.pubkey.trim().toLowerCase(); + } + + const effectiveScope = deriveRebroadcastScope(normalizedPubkey, normalizedId); + if (effectiveScope && !guardScope) { + guardScope = effectiveScope; + if (hasRecentRebroadcastAttempt(guardScope, guardBucket)) { + const cooldown = getRebroadcastCooldownState(guardScope); + return { ok: false, error: "cooldown-active", throttled: true, cooldown }; + } + } + + if (guardScope) { + rememberRebroadcastAttempt(guardScope, guardBucket); + } + + let countResult = null; + if (options.skipCount !== true) { + try { + countResult = + typeof client?.countEventsAcrossRelays === "function" + ? await client.countEventsAcrossRelays([ + { ids: [normalizedId] }, + ], { + relays, + timeoutMs: options.timeoutMs, + }) + : null; + } catch (error) { + devLogger.warn("[nostr] COUNT request for rebroadcast failed:", error); + } + + if (countResult?.total && Number(countResult.total) > 0) { + return { + ok: true, + alreadyPresent: true, + count: countResult, + cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, + }; + } + } + + const publishResults = await publishEventToRelays(client?.pool, relays, rawEvent); + + try { + const summary = assertAnyRelayAccepted(publishResults, { context: "rebroadcast" }); + return { + ok: true, + rebroadcast: true, + summary, + count: countResult, + cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, + }; + } catch (error) { + devLogger.warn("[nostr] Rebroadcast rejected by relays:", error); + return { + ok: false, + error: "publish-rejected", + details: error, + results: publishResults, + cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, + }; + } +} + +export { summarizePublishResults }; diff --git a/tests/nostr-boost-actions.test.mjs b/tests/nostr-boost-actions.test.mjs index 11c8f450..714813be 100644 --- a/tests/nostr-boost-actions.test.mjs +++ b/tests/nostr-boost-actions.test.mjs @@ -2,15 +2,79 @@ import "./test-helpers/setup-localstorage.mjs"; import assert from "node:assert/strict"; const { buildRepostEvent } = await import("../js/nostrEventSchemas.js"); -const { NostrClient } = await import("../js/nostr.js"); +const { + repostEvent, + mirrorVideoEvent, +} = await import("../js/nostr/publishHelpers.js"); -function createClientHarness() { - const client = new NostrClient(); - client.relays = ["wss://relay.example" ]; - client.writeRelays = ["wss://relay.example" ]; - client.pool = {}; - client.ensurePool = async () => client.pool; - return client; +const resolveActiveSignerStub = () => ({ + signEvent: async (event) => ({ + ...event, + id: `${event.kind}-${event.created_at}`, + sig: "active-signature", + }), +}); + +const shouldRequestExtensionPermissionsStub = () => false; + +const signEventWithPrivateKeyStub = (event) => ({ + ...event, + id: `${event.kind}-${event.created_at}-session`, + sig: "session-signature", +}); + +const inferMimeTypeStub = (url = "") => { + if (typeof url !== "string") { + return ""; + } + if (url.toLowerCase().endsWith(".mp4")) { + return "video/mp4"; + } + if (url.toLowerCase().endsWith(".webm")) { + return "video/webm"; + } + return ""; +}; + +const eventToAddressPointerStub = () => ""; + +function createPublishClient({ actorPubkey = "", sessionPubkey = "", failPublish = false } = {}) { + const publishCalls = []; + const pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (!failPublish && eventName === "ok") { + handler(); + } + if (failPublish && eventName === "failed") { + handler(new Error("publish failed")); + } + return true; + }, + }; + }, + }; + + const client = { + pubkey: actorPubkey, + relays: ["wss://relay.example"], + writeRelays: ["wss://relay.example"], + allEvents: new Map(), + rawEvents: new Map(), + pool, + ensurePool: async () => pool, + ensureSessionActor: async () => sessionPubkey, + ensureExtensionPermissions: async () => ({ ok: true }), + countEventsAcrossRelays: async () => ({ total: 0, perRelay: [] }), + }; + + if (sessionPubkey) { + client.sessionActor = { pubkey: sessionPubkey, privateKey: "session-private-key" }; + } + + return { client, publishCalls }; } (function testBuildRepostEventIncludesPointerTags() { @@ -35,21 +99,12 @@ function createClientHarness() { })(); await (async function testRepostEventUsesSessionActorAndDerivesAddress() { - const client = createClientHarness(); const eventId = "test-event"; const authorPubkey = "f".repeat(64); const sessionPubkey = "a".repeat(64); + const { client } = createPublishClient({ sessionPubkey }); - client.sessionActor = { pubkey: sessionPubkey, privateKey: "session-key" }; - client.ensureSessionActor = async () => sessionPubkey; - client.signAndPublishEvent = async (event, opts) => { - return { - signedEvent: event, - summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] }, - signerPubkey: sessionPubkey, - }; - }; - + client.rawEvents.set(eventId, { id: eventId, pubkey: authorPubkey }); client.allEvents.set(eventId, { id: eventId, pubkey: authorPubkey, @@ -58,49 +113,87 @@ await (async function testRepostEventUsesSessionActorAndDerivesAddress() { url: "https://cdn.example/video.mp4", }); - const result = await client.repostEvent(eventId, { - pointer: ["e", eventId, "wss://origin"], - authorPubkey, + const result = await repostEvent({ + client, + eventId, + options: { + pointer: ["e", eventId, "wss://origin"], + authorPubkey, + }, + resolveActiveSigner: () => null, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + eventToAddressPointer: eventToAddressPointerStub, }); assert.equal(result.ok, true); assert.equal(result.sessionActor, true); - assert.equal(Array.isArray(result.summary.accepted), true); - const tags = result.event?.tags; + assert.equal(result.signerPubkey, sessionPubkey); + const tags = result.event?.tags || []; assert.ok(Array.isArray(tags), "Repost event should include tags"); assert.deepEqual(tags[0], ["e", eventId, "wss://origin"]); assert.equal( tags.some((tag) => Array.isArray(tag) && tag[0] === "a" && tag[1].startsWith("30078:")), true, - "Repost event should include an address pointer" + "Repost event should include an address pointer", ); assert.equal( tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === authorPubkey), true, - "Repost event should include a p tag" + "Repost event should include a p tag", ); })(); +await (async function testRepostEventHandlesPublishFailure() { + const eventId = "repost-fail"; + const authorPubkey = "d".repeat(64); + const sessionPubkey = "b".repeat(64); + const { client } = createPublishClient({ sessionPubkey, failPublish: true }); + + client.allEvents.set(eventId, { + id: eventId, + pubkey: authorPubkey, + videoRootId: "root-id", + title: "Video", + }); + + const result = await repostEvent({ + client, + eventId, + options: { + pointer: ["e", eventId, "wss://origin"], + authorPubkey, + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + eventToAddressPointer: eventToAddressPointerStub, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "publish-rejected"); + assert.ok(result.details instanceof Error); +})(); + await (async function testMirrorEventIncludesHostedMetadata() { - const client = createClientHarness(); const eventId = "mirror-event"; const actorPubkey = "1".repeat(64); + const { client } = createPublishClient({ actorPubkey }); - client.pubkey = actorPubkey; - client.signAndPublishEvent = async (event, opts) => { - return { - signedEvent: event, - summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] }, - signerPubkey: actorPubkey, - }; - }; - - const result = await client.mirrorVideoEvent(eventId, { - url: "https://videos.example/video.mp4", - magnet: "magnet:?xt=urn:btih:abc", - thumbnail: "https://videos.example/thumb.jpg", - description: "Sample clip", - title: "Sample", + const result = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://videos.example/video.mp4", + magnet: "magnet:?xt=urn:btih:abc", + thumbnail: "https://videos.example/thumb.jpg", + description: "Sample clip", + title: "Sample", + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + inferMimeTypeFromUrl: inferMimeTypeStub, }); assert.equal(result.ok, true); @@ -115,23 +208,22 @@ await (async function testMirrorEventIncludesHostedMetadata() { })(); await (async function testMirrorSkipsMagnetWhenPrivate() { - const client = createClientHarness(); const eventId = "private-mirror"; const actorPubkey = "2".repeat(64); + const { client } = createPublishClient({ actorPubkey }); - client.pubkey = actorPubkey; - client.signAndPublishEvent = async (event, opts) => { - return { - signedEvent: event, - summary: { accepted: [{ url: opts.relaysOverride?.[0] || "wss://relay.example" }], failed: [] }, - signerPubkey: actorPubkey, - }; - }; - - const result = await client.mirrorVideoEvent(eventId, { - url: "https://videos.example/private.mp4", - magnet: "magnet:?xt=urn:btih:hidden", - isPrivate: true, + const result = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://videos.example/private.mp4", + magnet: "magnet:?xt=urn:btih:hidden", + isPrivate: true, + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + inferMimeTypeFromUrl: inferMimeTypeStub, }); assert.equal(result.ok, true); @@ -143,4 +235,27 @@ await (async function testMirrorSkipsMagnetWhenPrivate() { ); })(); +await (async function testMirrorEventPublishFailureIsSurfaced() { + const eventId = "mirror-fail"; + const actorPubkey = "3".repeat(64); + const { client } = createPublishClient({ actorPubkey, failPublish: true }); + + const result = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://videos.example/video.mp4", + title: "Failure", + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + inferMimeTypeFromUrl: inferMimeTypeStub, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "publish-rejected"); + assert.ok(result.details instanceof Error); +})(); + console.log("nostr boost action tests passed"); diff --git a/tests/nostr-rebroadcast-guard.test.mjs b/tests/nostr-rebroadcast-guard.test.mjs index c2d48d7c..5465bbed 100644 --- a/tests/nostr-rebroadcast-guard.test.mjs +++ b/tests/nostr-rebroadcast-guard.test.mjs @@ -6,15 +6,12 @@ import assert from "node:assert/strict"; const { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } = await import( "../js/config.js" ); -const { nostrClient } = await import("../js/nostr.js"); - -const NostrClient = nostrClient.constructor; +const { rebroadcastEvent } = await import("../js/nostr/publishHelpers.js"); function createRebroadcastHarness() { - const client = new NostrClient(); - client.relays = ["wss://relay.test"]; - const publishCalls = []; + const countCalls = []; + const pool = { publish(urls, event) { publishCalls.push({ urls, event }); @@ -28,12 +25,19 @@ function createRebroadcastHarness() { }; }, }; - client.pool = pool; - const countCalls = []; - client.countEventsAcrossRelays = async () => { - countCalls.push(Date.now()); - return { total: 0, perRelay: [] }; + const client = { + relays: ["wss://relay.test"], + writeRelays: ["wss://relay.test"], + pool, + ensurePool: async () => pool, + allEvents: new Map(), + rawEvents: new Map(), + countEventsAcrossRelays: async () => { + countCalls.push(Date.now()); + return { total: 0, perRelay: [] }; + }, + fetchRawEventById: async () => null, }; return { client, publishCalls, countCalls }; @@ -67,12 +71,20 @@ async function testRebroadcastCooldownThrottlesUntilWindowExpires() { Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) * 1000; try { - const first = await client.rebroadcastEvent(eventId, { pubkey }); + const first = await rebroadcastEvent({ + client, + eventId, + options: { pubkey }, + }); assert.equal(first.ok, true, "first attempt should succeed"); assert.equal(publishCalls.length, 1, "first attempt should publish once"); assert.equal(countCalls.length, 1, "first attempt should issue a count"); - const second = await client.rebroadcastEvent(eventId, { pubkey }); + const second = await rebroadcastEvent({ + client, + eventId, + options: { pubkey }, + }); assert.equal( second.throttled, true, @@ -95,7 +107,11 @@ async function testRebroadcastCooldownThrottlesUntilWindowExpires() { nowMs += cooldownMs + 1_000; - const third = await client.rebroadcastEvent(eventId, { pubkey }); + const third = await rebroadcastEvent({ + client, + eventId, + options: { pubkey }, + }); assert.equal(third.ok, true, "attempt after cooldown should succeed"); assert.equal(publishCalls.length, 2, "rebroadcast should fire again after cooldown"); assert.equal(countCalls.length, 2, "cooldown reset should allow another COUNT");