diff --git a/config/instance-config.js b/config/instance-config.js index cfb1b274..c4ac720b 100644 --- a/config/instance-config.js +++ b/config/instance-config.js @@ -238,3 +238,14 @@ export const VIEW_COUNT_BACKFILL_MAX_DAYS = 90; * fresher dashboards or raise it if your analytics traffic is heavy. */ export const VIEW_COUNT_CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Cooldown window (in seconds) between manual "Ensure presence" rebroadcast + * attempts. + * + * Rebroadcasting is an escape hatch for creators to nudge stale relays. A + * short cooldown keeps this safeguard from turning into an accidental DDoS. + * Five minutes provides enough breathing room for relays to catch up while + * still letting operators retry if a publish genuinely failed. + */ +export const ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS = 5 * 60; diff --git a/js/app.js b/js/app.js index 771c7877..e8c4f844 100644 --- a/js/app.js +++ b/js/app.js @@ -4959,6 +4959,10 @@ class Application { await this.handleRemoveHistoryAction(dataset); break; } + case "ensure-presence": { + await this.handleEnsurePresenceAction(dataset); + break; + } case "copy-npub": { const explicitNpub = typeof dataset.npub === "string" && dataset.npub.trim() @@ -5154,6 +5158,89 @@ class Application { } } + async handleEnsurePresenceAction(dataset = {}) { + const context = typeof dataset.context === "string" ? dataset.context : ""; + const explicitEventId = + typeof dataset.eventId === "string" && dataset.eventId.trim() + ? dataset.eventId.trim() + : ""; + const fallbackEventId = + context === "modal" && this.currentVideo?.id ? this.currentVideo.id : ""; + const targetEventId = explicitEventId || fallbackEventId; + + if (!targetEventId) { + this.showError("No event is available to rebroadcast."); + return; + } + + const explicitPubkey = + typeof dataset.pubkey === "string" && dataset.pubkey.trim() + ? dataset.pubkey.trim() + : ""; + const datasetAuthor = + typeof dataset.author === "string" && dataset.author.trim() + ? dataset.author.trim() + : ""; + const fallbackPubkey = + context === "modal" && typeof this.currentVideo?.pubkey === "string" + ? this.currentVideo.pubkey + : datasetAuthor; + const targetPubkey = explicitPubkey || fallbackPubkey || ""; + + try { + const result = await nostrClient.rebroadcastEvent(targetEventId, { + pubkey: targetPubkey, + }); + + if (result?.throttled) { + const remainingMs = Math.max(0, Number(result?.cooldown?.remainingMs) || 0); + const remainingSeconds = Math.ceil(remainingMs / 1000); + const message = + remainingSeconds > 0 + ? `Ensure presence is cooling down. Try again in ${remainingSeconds}s.` + : "Ensure presence is cooling down. Try again soon."; + this.showStatus(message); + if (typeof window !== "undefined" && typeof window.setTimeout === "function") { + window.setTimeout(() => { + this.showStatus(""); + }, 5000); + } + return; + } + + if (!result?.ok) { + const code = result?.error || "rebroadcast-failed"; + switch (code) { + case "event-not-found": + this.showError("Original event payload is unavailable. Reload and try again."); + break; + case "publish-rejected": + this.showError("No relay accepted the rebroadcast attempt."); + break; + case "pool-unavailable": + this.showError("Cannot reach relays right now. Please try again later."); + break; + default: + this.showError("Failed to ensure presence. Please try again later."); + break; + } + return; + } + + if (result?.alreadyPresent) { + this.showSuccess("Relays already have this revision."); + return; + } + + this.showSuccess("Rebroadcast requested across relays."); + } catch (error) { + if (isDevMode) { + console.warn("[app] Ensure presence action failed:", error); + } + this.showError("Failed to ensure presence. Please try again later."); + } + } + /** * Updates the modal to reflect current torrent stats. * We remove the unused torrent.status references, diff --git a/js/config.js b/js/config.js index e933cd9b..330819b1 100644 --- a/js/config.js +++ b/js/config.js @@ -22,6 +22,7 @@ import { VIEW_COUNT_DEDUPE_WINDOW_SECONDS, VIEW_COUNT_BACKFILL_MAX_DAYS, VIEW_COUNT_CACHE_TTL_MS, + ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, } from "../config/instance-config.js"; export const isDevMode = true; // Set to false for production @@ -83,3 +84,4 @@ export { WATCH_HISTORY_CACHE_TTL_MS }; export { VIEW_COUNT_DEDUPE_WINDOW_SECONDS }; export { VIEW_COUNT_BACKFILL_MAX_DAYS }; export { VIEW_COUNT_CACHE_TTL_MS }; +export { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS }; diff --git a/js/nostr.js b/js/nostr.js index b3fae45e..1d03e712 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -13,6 +13,7 @@ import { WATCH_HISTORY_PAYLOAD_MAX_BYTES, WATCH_HISTORY_FETCH_EVENT_LIMIT, WATCH_HISTORY_CACHE_TTL_MS, + ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS, } from "./config.js"; import { ACCEPT_LEGACY_V1, @@ -61,6 +62,7 @@ const NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE = "Timed out waiting for the NIP-07 extension. Check the extension prompt and try again."; const SESSION_ACTOR_STORAGE_KEY = "bitvid:sessionActor:v1"; const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed"; +const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1"; const WATCH_HISTORY_STORAGE_KEY = "bitvid:watch-history:v2"; const WATCH_HISTORY_STORAGE_VERSION = 2; @@ -70,6 +72,7 @@ const WATCH_HISTORY_REPUBLISH_MAX_ATTEMPTS = 8; const WATCH_HISTORY_REPUBLISH_JITTER = 0.25; const viewEventPublishMemory = new Map(); +const rebroadcastAttemptMemory = new Map(); const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT); const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind) @@ -1679,6 +1682,172 @@ function rememberViewPublish(scope, bucketIndex) { } } +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) { + if (isDevMode) { + console.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) { + if (isDevMode) { + console.warn("[nostr] Failed to clear corrupt rebroadcast guard entry:", error); + } + } + return null; + } + + if (now - storedSeenAt >= windowMs) { + try { + localStorage.removeItem(storageKey); + } catch (error) { + if (isDevMode) { + console.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) { + if (isDevMode) { + console.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, + }; +} + function isVideoViewEvent(event, pointer) { if (!event || typeof event !== "object") { return false; @@ -6911,6 +7080,135 @@ export class NostrClient { return video; } + 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) { + if (isDevMode) { + console.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) { + if (isDevMode) { + console.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) { + if (isDevMode) { + console.warn("[nostr] Rebroadcast rejected by relays:", error); + } + return { + ok: false, + error: "publish-rejected", + details: error, + results: publishResults, + cooldown: guardScope ? getRebroadcastCooldownState(guardScope) : null, + }; + } + } + getRequestTimeoutMs(timeoutMs) { const candidate = Number(timeoutMs); if (Number.isFinite(candidate) && candidate > 0) { diff --git a/tests/nostr-rebroadcast-guard.test.mjs b/tests/nostr-rebroadcast-guard.test.mjs new file mode 100644 index 00000000..c2d48d7c --- /dev/null +++ b/tests/nostr-rebroadcast-guard.test.mjs @@ -0,0 +1,109 @@ +// Run with: node tests/nostr-rebroadcast-guard.test.mjs + +import "./test-helpers/setup-localstorage.mjs"; +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; + +function createRebroadcastHarness() { + const client = new NostrClient(); + client.relays = ["wss://relay.test"]; + + const publishCalls = []; + const pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + client.pool = pool; + + const countCalls = []; + client.countEventsAcrossRelays = async () => { + countCalls.push(Date.now()); + return { total: 0, perRelay: [] }; + }; + + return { client, publishCalls, countCalls }; +} + +async function testRebroadcastCooldownThrottlesUntilWindowExpires() { + localStorage.clear(); + + const { client, publishCalls, countCalls } = createRebroadcastHarness(); + + const eventId = "rebroadcast-test-event"; + const pubkey = "f".repeat(64); + const rawEvent = { + id: eventId, + pubkey, + kind: 30078, + created_at: 1_700_000_000, + sig: "sig", + tags: [], + content: "{}", + }; + + client.rawEvents.set(eventId, rawEvent); + client.allEvents.set(eventId, { id: eventId, pubkey }); + + const originalNow = Date.now; + let nowMs = 1_700_000_000_000; + Date.now = () => nowMs; + + const cooldownMs = + Number(ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS) * 1000; + + try { + const first = await client.rebroadcastEvent(eventId, { 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 }); + assert.equal( + second.throttled, + true, + "cooldown guard should throttle the immediate retry", + ); + assert.equal( + publishCalls.length, + 1, + "throttled attempt must not publish again", + ); + assert.equal( + countCalls.length, + 1, + "throttled attempt must not issue another COUNT", + ); + assert.ok( + Number(second?.cooldown?.remainingMs) > 0, + "throttled response should include remaining cooldown", + ); + + nowMs += cooldownMs + 1_000; + + const third = await client.rebroadcastEvent(eventId, { 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"); + } finally { + Date.now = originalNow; + } +} + +await testRebroadcastCooldownThrottlesUntilWindowExpires(); + +console.log("nostr rebroadcast guard tests passed");