mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
1335 lines
37 KiB
JavaScript
1335 lines
37 KiB
JavaScript
import { ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "../config.js";
|
|
import {
|
|
publishEventToRelays,
|
|
assertAnyRelayAccepted,
|
|
summarizePublishResults,
|
|
} from "../nostrPublish.js";
|
|
import {
|
|
buildRepostEvent,
|
|
buildVideoMirrorEvent,
|
|
sanitizeAdditionalTags,
|
|
} from "../nostrEventSchemas.js";
|
|
import { normalizePointerInput } from "./watchHistory.js";
|
|
import { sanitizeRelayList } from "./nip46Client.js";
|
|
import {
|
|
RELAY_URLS,
|
|
getCachedNostrTools,
|
|
readToolkitFromScope,
|
|
} from "./toolkit.js";
|
|
import { DEFAULT_NIP07_PERMISSION_METHODS } from "./nip07Permissions.js";
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import { logRebroadcastCountFailure } from "./countDiagnostics.js";
|
|
import { queueSignEvent } from "./signRequestQueue.js";
|
|
|
|
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
|
|
const rebroadcastAttemptMemory = new Map();
|
|
|
|
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;
|
|
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
|
|
|
|
function normalizeSha256TagValue(value) {
|
|
if (value === undefined || value === null) {
|
|
return "";
|
|
}
|
|
const stringValue = typeof value === "string" ? value : String(value);
|
|
const trimmed = stringValue.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return "";
|
|
}
|
|
if (!HEX64_REGEX.test(trimmed)) {
|
|
return null;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
export function signEventWithPrivateKey(event, privateKey) {
|
|
const tools = getCachedNostrTools();
|
|
const scopedTools = readToolkitFromScope();
|
|
const canonicalTools =
|
|
typeof globalThis !== "undefined" &&
|
|
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
|
|
? globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__
|
|
: null;
|
|
const signFn =
|
|
typeof canonicalTools?.signEvent === "function"
|
|
? canonicalTools.signEvent
|
|
: typeof tools?.signEvent === "function"
|
|
? tools.signEvent
|
|
: typeof scopedTools?.signEvent === "function"
|
|
? scopedTools.signEvent
|
|
: null;
|
|
// console.debug('signEventWithPrivateKey', {
|
|
// signFn: typeof signFn,
|
|
// scopedSign: typeof scopedTools?.signEvent,
|
|
// cachedSign: typeof tools?.signEvent,
|
|
// });
|
|
const finalizeFn =
|
|
typeof canonicalTools?.finalizeEvent === "function"
|
|
? canonicalTools.finalizeEvent
|
|
: typeof tools?.finalizeEvent === "function"
|
|
? tools.finalizeEvent
|
|
: typeof scopedTools?.finalizeEvent === "function"
|
|
? scopedTools.finalizeEvent
|
|
: null;
|
|
const hashFn =
|
|
typeof canonicalTools?.getEventHash === "function"
|
|
? canonicalTools.getEventHash
|
|
: typeof tools?.getEventHash === "function"
|
|
? tools.getEventHash
|
|
: typeof scopedTools?.getEventHash === "function"
|
|
? scopedTools.getEventHash
|
|
: null;
|
|
|
|
if (
|
|
!privateKey ||
|
|
typeof privateKey !== "string" ||
|
|
(!signFn && !finalizeFn)
|
|
) {
|
|
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 : "",
|
|
};
|
|
|
|
if (signFn) {
|
|
const signature = signFn(prepared, privateKey);
|
|
if (signature && typeof signature === "object") {
|
|
const idValue =
|
|
typeof signature.id === "string"
|
|
? signature.id
|
|
: hashFn
|
|
? hashFn(prepared)
|
|
: null;
|
|
const sigValue =
|
|
typeof signature.sig === "string"
|
|
? signature.sig
|
|
: typeof signature.signature === "string"
|
|
? signature.signature
|
|
: null;
|
|
if (!idValue || !sigValue) {
|
|
throw new Error("Missing signing primitives");
|
|
}
|
|
return { ...prepared, id: idValue, sig: sigValue };
|
|
}
|
|
|
|
const sigValue = typeof signature === "string" ? signature : null;
|
|
if (!sigValue || !hashFn) {
|
|
throw new Error("Missing signing primitives");
|
|
}
|
|
const idValue = hashFn(prepared);
|
|
return { ...prepared, id: idValue, sig: sigValue };
|
|
}
|
|
|
|
const normalizedKey = privateKey.trim();
|
|
if (!HEX_PRIVATE_KEY_REGEX.test(normalizedKey)) {
|
|
throw new Error("Missing signing primitives");
|
|
}
|
|
|
|
const finalized = finalizeFn(prepared, normalizedKey);
|
|
if (
|
|
!finalized ||
|
|
typeof finalized.id !== "string" ||
|
|
typeof finalized.sig !== "string"
|
|
) {
|
|
throw new Error("Missing signing primitives");
|
|
}
|
|
return finalized;
|
|
}
|
|
|
|
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 queueSignEvent(signer, event, {
|
|
timeoutMs: options.timeoutMs,
|
|
});
|
|
} 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 resolvedRawEvent = cachedRaw;
|
|
const serializeEvent = (event) => {
|
|
if (!event || typeof event !== "object") {
|
|
return "";
|
|
}
|
|
try {
|
|
return JSON.stringify(event);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
`[nostr] Failed to serialize repost target ${normalizedId}`,
|
|
error,
|
|
);
|
|
return "";
|
|
}
|
|
};
|
|
let serializedSourceEvent = serializeEvent(resolvedRawEvent);
|
|
|
|
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;
|
|
}
|
|
|
|
const relayCandidates = new Set();
|
|
|
|
const rememberRelayCandidate = (value) => {
|
|
if (typeof value !== "string") {
|
|
return;
|
|
}
|
|
const normalized = value.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
relayCandidates.add(normalized);
|
|
};
|
|
|
|
const rememberPointerRelay = (pointerCandidate) => {
|
|
const normalizedPointer = normalizePointerInput(pointerCandidate);
|
|
if (normalizedPointer?.relay) {
|
|
rememberRelayCandidate(normalizedPointer.relay);
|
|
}
|
|
};
|
|
|
|
const rememberRelayHintsFromObject = (candidate, depth = 0) => {
|
|
if (!candidate || typeof candidate !== "object" || depth > 3) {
|
|
return;
|
|
}
|
|
|
|
const directKeys = [
|
|
"relay",
|
|
"relayUrl",
|
|
"eventRelay",
|
|
"pointerRelay",
|
|
"sourceRelay",
|
|
"originRelay",
|
|
"addressRelay",
|
|
];
|
|
directKeys.forEach((key) => {
|
|
if (typeof candidate[key] === "string") {
|
|
rememberRelayCandidate(candidate[key]);
|
|
}
|
|
});
|
|
|
|
if (Array.isArray(candidate.relays)) {
|
|
candidate.relays.forEach((value) => {
|
|
rememberRelayCandidate(value);
|
|
});
|
|
}
|
|
|
|
if (candidate.pointer) {
|
|
rememberPointerRelay(candidate.pointer);
|
|
}
|
|
if (candidate.eventPointer) {
|
|
rememberPointerRelay(candidate.eventPointer);
|
|
}
|
|
|
|
if (candidate.pointerInfo && depth <= 3) {
|
|
rememberRelayHintsFromObject(candidate.pointerInfo, depth + 1);
|
|
}
|
|
|
|
if (Array.isArray(candidate.pointers)) {
|
|
candidate.pointers.forEach((pointerCandidate) => {
|
|
rememberPointerRelay(pointerCandidate);
|
|
});
|
|
}
|
|
|
|
if (candidate.video && depth <= 3) {
|
|
rememberRelayHintsFromObject(candidate.video, depth + 1);
|
|
}
|
|
};
|
|
|
|
let eventRelay =
|
|
typeof options.eventRelay === "string" ? options.eventRelay.trim() : "";
|
|
if (eventRelay) {
|
|
rememberRelayCandidate(eventRelay);
|
|
}
|
|
|
|
if (pointer) {
|
|
rememberPointerRelay(pointer);
|
|
if (!eventRelay && pointer.type === "e" && pointer.relay) {
|
|
eventRelay = pointer.relay;
|
|
}
|
|
}
|
|
|
|
rememberRelayHintsFromObject(options);
|
|
rememberRelayHintsFromObject(pointer);
|
|
rememberRelayHintsFromObject(cachedVideo);
|
|
rememberRelayHintsFromObject(cachedVideo?.pointerInfo);
|
|
rememberRelayHintsFromObject(cachedRaw);
|
|
|
|
if (addressRelay) {
|
|
rememberRelayCandidate(addressRelay);
|
|
}
|
|
|
|
const deriveRelayFromRawEvent = (event) => {
|
|
if (!event || typeof event !== "object") {
|
|
return "";
|
|
}
|
|
const directRelay =
|
|
typeof event.relay === "string" ? event.relay.trim() : "";
|
|
if (directRelay) {
|
|
return directRelay;
|
|
}
|
|
const relayCollections = [event.relays, event.seenOn, event.seen_on];
|
|
for (const collection of relayCollections) {
|
|
if (!Array.isArray(collection)) {
|
|
continue;
|
|
}
|
|
for (const entry of collection) {
|
|
if (typeof entry === "string" && entry.trim()) {
|
|
return entry.trim();
|
|
}
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const cachedRawRelay = deriveRelayFromRawEvent(cachedRaw);
|
|
if (cachedRawRelay) {
|
|
rememberRelayCandidate(cachedRawRelay);
|
|
if (!eventRelay) {
|
|
eventRelay = cachedRawRelay;
|
|
}
|
|
}
|
|
|
|
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);
|
|
const probeRelays = (() => {
|
|
const order = [];
|
|
if (eventRelay) {
|
|
order.push(eventRelay);
|
|
}
|
|
relayCandidates.forEach((relayUrl) => {
|
|
if (relayUrl && !order.includes(relayUrl)) {
|
|
order.push(relayUrl);
|
|
}
|
|
});
|
|
relays.forEach((relayUrl) => {
|
|
if (relayUrl && !order.includes(relayUrl)) {
|
|
order.push(relayUrl);
|
|
}
|
|
});
|
|
return order;
|
|
})();
|
|
|
|
if ((!eventRelay || !resolvedRawEvent) && typeof client?.fetchRawEventById === "function") {
|
|
for (const relayUrl of probeRelays) {
|
|
if (!relayUrl) {
|
|
continue;
|
|
}
|
|
if (!resolvedRawEvent) {
|
|
try {
|
|
const fetched = await client.fetchRawEventById(normalizedId, {
|
|
relays: [relayUrl],
|
|
});
|
|
if (fetched) {
|
|
resolvedRawEvent = fetched;
|
|
serializedSourceEvent = serializeEvent(fetched);
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
`[nostr] Failed to fetch ${normalizedId} from ${relayUrl} while preparing repost`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!eventRelay && resolvedRawEvent) {
|
|
eventRelay = relayUrl;
|
|
rememberRelayCandidate(relayUrl);
|
|
}
|
|
|
|
if (eventRelay && resolvedRawEvent) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (resolvedRawEvent && !serializedSourceEvent) {
|
|
serializedSourceEvent = serializeEvent(resolvedRawEvent);
|
|
}
|
|
|
|
if (resolvedRawEvent && !eventRelay) {
|
|
const rawRelay = deriveRelayFromRawEvent(resolvedRawEvent);
|
|
if (rawRelay) {
|
|
eventRelay = rawRelay;
|
|
rememberRelayCandidate(rawRelay);
|
|
}
|
|
}
|
|
|
|
if (!eventRelay) {
|
|
devLogger.warn(
|
|
`[nostr] Repost aborted: missing source relay for ${normalizedId}`,
|
|
{ candidates: probeRelays },
|
|
);
|
|
return {
|
|
ok: false,
|
|
error: "missing-event-relay",
|
|
details: { relays: probeRelays },
|
|
};
|
|
}
|
|
|
|
devLogger.log(
|
|
`[nostr] Resolved repost target relay ${eventRelay} for ${normalizedId}`,
|
|
);
|
|
|
|
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 = sanitizeAdditionalTags(options.additionalTags);
|
|
|
|
const repostKind =
|
|
Number.isFinite(targetKind) && targetKind !== 1
|
|
? 16
|
|
: 6;
|
|
|
|
const repostEventPayload = buildRepostEvent({
|
|
pubkey: actorPubkey,
|
|
created_at: createdAt,
|
|
eventId: normalizedId,
|
|
eventRelay,
|
|
address,
|
|
addressRelay,
|
|
authorPubkey,
|
|
additionalTags,
|
|
repostKind,
|
|
targetKind,
|
|
targetEvent: resolvedRawEvent,
|
|
serializedEvent: serializedSourceEvent,
|
|
});
|
|
|
|
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;
|
|
|
|
const sourceInfo = serializedSourceEvent
|
|
? { raw: resolvedRawEvent, serialized: serializedSourceEvent }
|
|
: null;
|
|
|
|
return {
|
|
ok: true,
|
|
event: signedEvent,
|
|
summary,
|
|
relays,
|
|
sessionActor: usedSessionActor,
|
|
signerPubkey,
|
|
source: sourceInfo,
|
|
};
|
|
} 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 normalizedProvidedMimeType = providedMimeType
|
|
? providedMimeType.toLowerCase()
|
|
: "";
|
|
const inferredMimeType = inferMimeTypeFromUrl
|
|
? inferMimeTypeFromUrl(url)
|
|
: "";
|
|
const normalizedInferredMimeType = inferredMimeType
|
|
? inferredMimeType.toLowerCase()
|
|
: "";
|
|
const mimeTypeSource =
|
|
normalizedProvidedMimeType ||
|
|
normalizedInferredMimeType ||
|
|
"application/octet-stream";
|
|
const mimeType = mimeTypeSource.toLowerCase();
|
|
|
|
const explicitAlt = sanitize(options.altText);
|
|
const altText = explicitAlt || description || title || "";
|
|
|
|
const fileSha256 = normalizeSha256TagValue(options.fileSha256);
|
|
if (fileSha256 === null) {
|
|
return { ok: false, error: "invalid-file-sha" };
|
|
}
|
|
|
|
const originalFileSha256 = normalizeSha256TagValue(
|
|
options.originalFileSha256,
|
|
);
|
|
if (originalFileSha256 === null) {
|
|
return { ok: false, error: "invalid-original-file-sha" };
|
|
}
|
|
|
|
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]);
|
|
}
|
|
if (fileSha256) {
|
|
tags.push(["x", fileSha256]);
|
|
}
|
|
if (originalFileSha256) {
|
|
tags.push(["ox", originalFileSha256]);
|
|
}
|
|
|
|
const additionalTags = sanitizeAdditionalTags(options.additionalTags);
|
|
if (additionalTags.length) {
|
|
tags.push(...additionalTags.map((tag) => tag.slice()));
|
|
}
|
|
|
|
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) {
|
|
logRebroadcastCountFailure(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 };
|