This commit is contained in:
thePR0M3TH3AN committed 2025-11-12 11:06:10 -05:00
1 parent 000127f88c
commit 071d78dd6c
194 files changed
+28793 -3944

No files matched your search

+563 -126
View File
@@ -68,6 +68,305 @@ const BLOCKLIST_STORAGE_PREFIX = "bitvid:user-blocks";
const BLOCKLIST_SEEDED_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:seeded:v1`;
const BLOCKLIST_REMOVALS_KEY_PREFIX = `${BLOCKLIST_STORAGE_PREFIX}:removals:v1`;
function normalizeEncryptionToken(value) {
if (typeof value !== "string") {
return "";
}
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
function extractEncryptionHints(event) {
if (!event || typeof event !== "object") {
return [];
}
const tags = Array.isArray(event.tags) ? event.tags : [];
const hints = [];
const pushUnique = (scheme) => {
if (!scheme || hints.includes(scheme)) {
return;
}
hints.push(scheme);
};
for (const tag of tags) {
if (!Array.isArray(tag) || tag.length < 2) {
continue;
}
const name = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (name !== "encrypted" && name !== "encryption") {
continue;
}
const rawValue = typeof tag[1] === "string" ? tag[1] : "";
if (!rawValue) {
continue;
}
const parts = rawValue
.split(/[\s,]+/)
.map((part) => normalizeEncryptionToken(part))
.filter(Boolean);
for (const part of parts) {
if (part === "nip44v2" || part === "nip44v02") {
pushUnique("nip44_v2");
continue;
}
if (part === "nip44") {
pushUnique("nip44");
continue;
}
if (part === "nip04" || part === "nip4") {
pushUnique("nip04");
}
}
}
return hints;
}
function determineDecryptionOrder(event, availableSchemes) {
const available = Array.isArray(availableSchemes) ? availableSchemes : [];
const availableSet = new Set(available);
const prioritized = [];
const hints = extractEncryptionHints(event);
const aliasMap = {
nip04: ["nip04"],
nip44: ["nip44", "nip44_v2"],
nip44_v2: ["nip44_v2", "nip44"],
};
for (const hint of hints) {
const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint];
for (const candidate of candidates) {
if (availableSet.has(candidate) && !prioritized.includes(candidate)) {
prioritized.push(candidate);
break;
}
}
}
for (const fallback of ["nip44_v2", "nip44", "nip04"]) {
if (availableSet.has(fallback) && !prioritized.includes(fallback)) {
prioritized.push(fallback);
}
}
return prioritized.length ? prioritized : available;
}
function describeDiscardedEntry(entry) {
if (typeof entry === "string") {
return entry.trim();
}
if (entry === null) {
return "null";
}
if (typeof entry === "undefined") {
return "undefined";
}
if (Array.isArray(entry)) {
try {
return JSON.stringify(entry);
} catch (_error) {
return String(entry);
}
}
if (typeof entry === "object") {
try {
return JSON.stringify(entry);
} catch (_error) {
return String(entry);
}
}
return String(entry);
}
function sanitizeMuteTags(values, ownerPubkey, options = {}) {
const logDiscarded = options?.logDiscarded !== false;
const owner = normalizeHex(ownerPubkey);
const sanitized = [];
const seen = new Set();
const iterable = Array.isArray(values)
? values
: values instanceof Set
? Array.from(values)
: values && typeof values[Symbol.iterator] === "function"
? Array.from(values)
: [];
const logDiscard = (entry, reason, extra) => {
if (!logDiscarded) {
return;
}
const detail = describeDiscardedEntry(entry);
if (extra) {
userLogger.warn(
`[UserBlockList] Discarded block entry (${reason}).`,
detail,
extra,
);
return;
}
userLogger.warn(`[UserBlockList] Discarded block entry (${reason}).`, detail);
};
for (const entry of iterable) {
let targetHex = null;
if (Array.isArray(entry)) {
if (entry.length < 2) {
logDiscard(entry, "invalid-tuple");
continue;
}
const label =
typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
if (label !== "p") {
logDiscard(entry, "unsupported-tag");
continue;
}
targetHex = normalizeHex(entry[1]);
if (!targetHex) {
logDiscard(entry, "invalid-target");
continue;
}
} else if (typeof entry === "string") {
targetHex = normalizeHex(entry);
if (!targetHex) {
logDiscard(entry, "invalid-string");
continue;
}
} else if (entry && typeof entry === "object") {
if (typeof entry.pubkey === "string") {
targetHex = normalizeHex(entry.pubkey);
if (!targetHex) {
logDiscard(entry, "invalid-object-pubkey");
continue;
}
} else {
logDiscard(entry, "unsupported-entry");
continue;
}
} else {
logDiscard(entry, "unsupported-entry");
continue;
}
if (owner && targetHex === owner) {
logDiscard(entry, "self-target");
continue;
}
if (seen.has(targetHex)) {
logDiscard(entry, "duplicate", targetHex);
continue;
}
seen.add(targetHex);
sanitized.push(["p", targetHex]);
}
return sanitized;
}
function serializeBlockListTagMatrix(values, ownerPubkey, options = {}) {
const tags = sanitizeMuteTags(values, ownerPubkey, options);
return JSON.stringify(tags);
}
function parseBlockListPlaintext(plaintext, ownerPubkey) {
if (typeof plaintext !== "string" || !plaintext) {
return [];
}
let parsed;
try {
parsed = JSON.parse(plaintext);
} catch (error) {
userLogger.warn(
"[UserBlockList] Failed to parse block list ciphertext as JSON; treating as empty.",
error,
);
return [];
}
const owner = normalizeHex(ownerPubkey);
const extractFromTags = (tags) => {
const collected = [];
const seen = new Set();
for (const entry of tags) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const label = typeof entry[0] === "string" ? entry[0].trim().toLowerCase() : "";
if (label !== "p") {
continue;
}
const normalized = normalizeHex(entry[1]);
if (!normalized || normalized === owner || seen.has(normalized)) {
continue;
}
seen.add(normalized);
collected.push(normalized);
}
return collected;
};
if (Array.isArray(parsed)) {
return extractFromTags(parsed);
}
if (parsed && typeof parsed === "object") {
if (Array.isArray(parsed.tags) && parsed.tags.length) {
return extractFromTags(parsed.tags);
}
const legacy = Array.isArray(parsed.blockedPubkeys) ? parsed.blockedPubkeys : [];
return legacy
.map((value) => normalizeHex(value))
.filter((value) => value && value !== owner);
}
return [];
}
function isUserBlockListEvent(event) {
if (!event || typeof event.kind !== "number") {
return false;
}
if (event.kind === 10000) {
return true;
}
if (event.kind === 30002) {
const tags = Array.isArray(event.tags) ? event.tags : [];
return tags.some(
(tag) =>
Array.isArray(tag) &&
typeof tag[0] === "string" &&
tag[0].trim().toLowerCase() === "d" &&
typeof tag[1] === "string" &&
tag[1].trim() === BLOCK_LIST_IDENTIFIER,
);
}
return false;
}
function decodeNpubToHex(npub) {
if (typeof npub !== "string") {
return null;
@@ -343,63 +642,115 @@ class UserBlockListManager {
});
};
const activeSigner = getActiveSigner();
const signerDecryptor =
activeSigner && typeof activeSigner.nip04Decrypt === "function"
? activeSigner.nip04Decrypt
: null;
const resolveDecryptors = async (event) => {
const signer = getActiveSigner();
const signerHasNip04 = typeof signer?.nip04Decrypt === "function";
const signerHasNip44 = typeof signer?.nip44Decrypt === "function";
let extensionDecryptor =
typeof window?.nostr?.nip04?.decrypt === "function"
? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload)
: null;
const hints = extractEncryptionHints(event);
const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2");
const requiresNip04 = !hints.length || hints.includes("nip04");
if (!signerDecryptor && !extensionDecryptor) {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult.ok) {
userLogger.warn(
"[UserBlockList] Unable to load block list via extension decryptor; permissions denied.",
permissionResult.error,
);
this.reset();
this.loaded = true;
const error =
permissionResult.error instanceof Error
? permissionResult.error
: new Error(
"Extension permissions are required to use the browser decryptor.",
);
emitStatus({ status: "error", error, decryptor: "extension" });
emitStatus({ status: "settled" });
return;
let permissionError = null;
if ((!signerHasNip44 && requiresNip44) || (!signerHasNip04 && requiresNip04)) {
try {
const permissionResult = await requestDefaultExtensionPermissions();
if (!permissionResult?.ok) {
permissionError =
permissionResult?.error instanceof Error
? permissionResult.error
: new Error(
"Extension permissions are required to use the browser decryptor.",
);
}
} catch (error) {
permissionError = error instanceof Error ? error : new Error(String(error));
}
}
extensionDecryptor =
typeof window?.nostr?.nip04?.decrypt === "function"
? (pubkey, payload) => window.nostr.nip04.decrypt(pubkey, payload)
: null;
}
const decryptors = new Map();
const sources = new Map();
const registerDecryptor = (scheme, handler, source) => {
if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) {
return;
}
decryptors.set(scheme, handler);
sources.set(scheme, source || "unknown");
};
if (!signerDecryptor && !extensionDecryptor) {
userLogger.warn(
"[UserBlockList] No nip04 decryptor available; treating block list as empty.",
);
this.reset();
this.loaded = true;
const error = new Error(
"No NIP-04 decryptor is available to load the block list.",
);
emitStatus({ status: "error", error, decryptor: "unavailable" });
emitStatus({ status: "settled" });
return;
}
if (signerHasNip44) {
registerDecryptor(
"nip44",
(payload) => signer.nip44Decrypt(normalized, payload),
"active-signer",
);
registerDecryptor(
"nip44_v2",
(payload) => signer.nip44Decrypt(normalized, payload),
"active-signer",
);
}
if (signerHasNip04) {
registerDecryptor(
"nip04",
(payload) => signer.nip04Decrypt(normalized, payload),
"active-signer",
);
}
const nostrApi = typeof window !== "undefined" ? window?.nostr : null;
if (nostrApi) {
if (typeof nostrApi.nip04?.decrypt === "function") {
registerDecryptor(
"nip04",
(payload) => nostrApi.nip04.decrypt(normalized, payload),
"extension",
);
}
const nip44 =
nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null;
if (nip44) {
if (typeof nip44.decrypt === "function") {
registerDecryptor(
"nip44",
(payload) => nip44.decrypt(normalized, payload),
"extension",
);
}
const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null;
if (nip44v2 && typeof nip44v2.decrypt === "function") {
registerDecryptor(
"nip44_v2",
(payload) => nip44v2.decrypt(normalized, payload),
"extension",
);
if (!decryptors.has("nip44")) {
registerDecryptor(
"nip44",
(payload) => nip44v2.decrypt(normalized, payload),
"extension",
);
}
}
}
}
return {
decryptors,
sources,
permissionError,
order: determineDecryptionOrder(event, Array.from(decryptors.keys())),
};
};
try {
const filter = {
kinds: [30002],
kinds: [10000, 30002],
authors: [normalized],
"#d": [BLOCK_LIST_IDENTIFIER],
limit: 1,
limit: 2,
};
if (since !== null) {
@@ -459,7 +810,10 @@ class UserBlockListManager {
settled = true;
clearTimeout(timer);
const events = Array.isArray(result)
? result.filter((event) => event && event.pubkey === normalized)
? result.filter(
(event) =>
event && event.pubkey === normalized && isUserBlockListEvent(event),
)
: [];
if (requireEvent && !events.length) {
const emptyError = new Error(
@@ -512,7 +866,10 @@ class UserBlockListManager {
}
const sorted = events
.filter((event) => event && event.pubkey === normalized)
.filter(
(event) =>
event && event.pubkey === normalized && isUserBlockListEvent(event),
)
.sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0));
if (!sorted.length) {
@@ -573,36 +930,92 @@ class UserBlockListManager {
return;
}
let decrypted = "";
const decryptPath = signerDecryptor
? "active-signer"
: extensionDecryptor
? "extension"
: "unavailable";
try {
if (signerDecryptor) {
decrypted = await signerDecryptor(normalized, newest.content);
} else if (extensionDecryptor) {
decrypted = await extensionDecryptor(normalized, newest.content);
} else {
throw new Error("nip04-unavailable");
const {
decryptors,
order,
sources,
permissionError,
} = await resolveDecryptors(newest);
if (!decryptors.size) {
if (permissionError) {
userLogger.warn(
"[UserBlockList] Unable to load block list via extension decryptor; permissions denied.",
permissionError,
);
this.reset();
this.loaded = true;
emitStatus({ status: "error", error: permissionError, decryptor: "extension" });
emitStatus({ status: "settled" });
return;
}
} catch (err) {
userLogger.warn(
"[UserBlockList] No decryptors available; treating block list as empty.",
);
this.reset();
this.loaded = true;
const error = new Error(
"No decryptors are available to load the block list.",
);
emitStatus({ status: "error", error, decryptor: "unavailable" });
emitStatus({ status: "settled" });
return;
}
let decrypted = "";
let schemeUsed = "";
let schemeSource = "unavailable";
const attemptErrors = [];
for (const scheme of order) {
const handler = decryptors.get(scheme);
if (typeof handler !== "function") {
continue;
}
const sourceLabel = sources.get(scheme) || "unknown";
try {
const plaintext = await handler(newest.content);
if (typeof plaintext !== "string") {
throw new Error("Decryption returned a non-string payload.");
}
decrypted = plaintext;
schemeUsed = scheme;
schemeSource = sourceLabel;
break;
} catch (error) {
attemptErrors.push({ scheme, source: sourceLabel, error });
}
}
if (!decrypted) {
const error = new Error(
"Failed to decrypt block list with available schemes.",
);
error.code = "block-list-decrypt-failed";
if (attemptErrors.length) {
error.cause = attemptErrors;
}
const lastAttempt = attemptErrors.length
? attemptErrors[attemptErrors.length - 1]
: null;
const decryptPath = lastAttempt?.source || "unavailable";
userLogger.error(
`[UserBlockList] Failed to decrypt block list via ${decryptPath}:`,
err,
`[UserBlockList] Failed to decrypt block list via available schemes (last: ${decryptPath}).`,
error,
);
applyBlockedPubkeys([], {
source,
reason: "decrypt-error",
event: newest,
error: err,
error,
decryptor: decryptPath,
errors: attemptErrors,
});
emitStatus({
status: "error",
event: newest,
error: err,
error,
source,
decryptor: decryptPath,
});
@@ -610,31 +1023,21 @@ class UserBlockListManager {
}
try {
const parsed = JSON.parse(decrypted);
const list = Array.isArray(parsed?.blockedPubkeys)
? parsed.blockedPubkeys
: [];
const sanitized = list
.map((entry) => normalizeHex(entry))
.filter((candidate) => {
if (!candidate) {
return false;
}
if (candidate === normalized) {
return false;
}
return true;
});
const sanitized = parseBlockListPlaintext(decrypted, normalized);
applyBlockedPubkeys(sanitized, {
source,
reason: "applied",
event: newest,
scheme: schemeUsed,
decryptor: schemeSource,
});
emitStatus({
status: "applied",
event: newest,
blockedPubkeys: Array.from(this.blockedPubkeys),
source,
scheme: schemeUsed,
decryptor: schemeSource,
});
} catch (err) {
userLogger.error("[UserBlockList] Failed to parse block list:", err);
@@ -643,8 +1046,17 @@ class UserBlockListManager {
reason: "parse-error",
event: newest,
error: err,
decryptor: schemeSource,
scheme: schemeUsed,
});
emitStatus({
status: "error",
event: newest,
error: err,
source,
decryptor: schemeSource,
scheme: schemeUsed,
});
emitStatus({ status: "error", event: newest, error: err, source });
}
};
@@ -958,30 +1370,7 @@ class UserBlockListManager {
return null;
}
const tagSet = new Set();
const tags = [];
const iterateBlocked = (input) => {
if (Array.isArray(input)) {
return input;
}
if (input instanceof Set) {
return Array.from(input);
}
if (input && typeof input[Symbol.iterator] === "function") {
return Array.from(input);
}
return [];
};
for (const value of iterateBlocked(blockedPubkeys)) {
const normalized = normalizeHex(value);
if (!normalized || normalized === owner || tagSet.has(normalized)) {
continue;
}
tagSet.add(normalized);
tags.push(["p", normalized]);
}
const tags = sanitizeMuteTags(blockedPubkeys, owner);
const timestamp = Number.isFinite(createdAt)
? Math.max(0, Math.floor(createdAt))
@@ -1107,14 +1496,6 @@ class UserBlockListManager {
}
}
if (typeof signer.nip04Encrypt !== "function") {
const err = new Error(
"NIP-04 encryption is required to update the block list."
);
err.code = "nip04-missing";
throw err;
}
if (typeof signer.signEvent !== "function") {
const err = new Error("Active signer missing signEvent support.");
err.code = "sign-event-missing";
@@ -1126,26 +1507,82 @@ class UserBlockListManager {
throw new Error("Invalid user pubkey.");
}
const sanitizedTags = sanitizeMuteTags(this.blockedPubkeys, normalized);
const payload = {
blockedPubkeys: Array.from(this.blockedPubkeys).filter(
(candidate) => candidate && candidate !== normalized
),
blockedPubkeys: sanitizedTags.map(([, target]) => target),
};
const plaintext = JSON.stringify(payload);
const plaintext = serializeBlockListTagMatrix(sanitizedTags, normalized, {
logDiscarded: false,
});
let cipherText = "";
try {
cipherText = await signer.nip04Encrypt(normalized, plaintext);
} catch (error) {
const err = new Error("Failed to encrypt block list.");
err.code = "nip04-missing";
const encryptors = [];
const registerEncryptor = (scheme, handler) => {
if (!scheme || typeof handler !== "function") {
return;
}
encryptors.push({ scheme, handler });
};
if (typeof signer.nip44Encrypt === "function") {
registerEncryptor("nip44_v2", (value) => signer.nip44Encrypt(normalized, value));
registerEncryptor("nip44", (value) => signer.nip44Encrypt(normalized, value));
}
if (typeof signer.nip04Encrypt === "function") {
registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value));
}
if (!encryptors.length) {
const err = new Error(
"An encryption-capable signer is required to update the block list."
);
err.code = "block-list-missing-encryptor";
throw err;
}
let cipherText = "";
let encryptionScheme = "";
const seenSchemes = new Set();
const encryptionErrors = [];
for (const candidate of encryptors) {
if (seenSchemes.has(candidate.scheme)) {
continue;
}
seenSchemes.add(candidate.scheme);
try {
const encrypted = await candidate.handler(plaintext);
if (typeof encrypted === "string" && encrypted) {
cipherText = encrypted;
encryptionScheme = candidate.scheme;
break;
}
} catch (error) {
encryptionErrors.push({ scheme: candidate.scheme, error });
}
}
if (!cipherText) {
const err = new Error("Failed to encrypt block list.");
err.code = "block-list-encrypt-failed";
err.cause = encryptionErrors;
throw err;
}
const encryptionTagValue =
encryptionScheme === "nip44_v2"
? "nip44_v2"
: encryptionScheme === "nip44"
? "nip44"
: encryptionScheme === "nip04"
? "nip04"
: undefined;
const event = buildBlockListEvent({
pubkey: normalized,
created_at: Math.floor(Date.now() / 1000),
content: cipherText,
encryption: encryptionTagValue,
});
const signedEvent = await signer.signEvent(event);