mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor nostr publish helpers into shared module
This commit is contained in:
1 parent
920d105660
commit
cce9cf6973
4 files changed
+1216
-967
No files matched your search
+34
-897
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user