mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #888 from PR0M3TH3AN/codex/implement-nip-51-mute-list-and-tests
Publish trusted mute lists with block updates
This commit is contained in:
4 files changed
+361
-1
No files matched your search
@@ -246,6 +246,8 @@ class UserBlockListManager {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
this.muteEventId = null;
|
||||
this.muteEventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
this.emitter = new TinyEventEmitter();
|
||||
this.seedStateCache = new Map();
|
||||
@@ -256,6 +258,8 @@ class UserBlockListManager {
|
||||
this.blockEventId = null;
|
||||
this.blockEventCreatedAt = null;
|
||||
this.lastPublishedCreatedAt = null;
|
||||
this.muteEventId = null;
|
||||
this.muteEventCreatedAt = null;
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
@@ -941,6 +945,137 @@ class UserBlockListManager {
|
||||
}
|
||||
}
|
||||
|
||||
async publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey,
|
||||
blockedPubkeys = [],
|
||||
createdAt,
|
||||
plaintext = "",
|
||||
onStatus,
|
||||
} = {}) {
|
||||
const owner = normalizeHex(ownerPubkey);
|
||||
if (!owner || !signer || typeof signer.signEvent !== "function") {
|
||||
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 timestamp = Number.isFinite(createdAt)
|
||||
? Math.max(0, Math.floor(createdAt))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const event = {
|
||||
kind: 10000,
|
||||
pubkey: owner,
|
||||
created_at: timestamp,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
|
||||
const payloadText = typeof plaintext === "string" ? plaintext : "";
|
||||
let encryptedContent = "";
|
||||
let encryptionTagValue = "";
|
||||
|
||||
if (payloadText) {
|
||||
const encryptionCandidates = [];
|
||||
|
||||
if (typeof signer.nip44Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt });
|
||||
}
|
||||
|
||||
if (typeof signer.nip04Encrypt === "function") {
|
||||
encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt });
|
||||
}
|
||||
|
||||
for (const candidate of encryptionCandidates) {
|
||||
try {
|
||||
const encrypted = await candidate.encrypt(owner, payloadText);
|
||||
if (typeof encrypted === "string" && encrypted) {
|
||||
encryptedContent = encrypted;
|
||||
encryptionTagValue = candidate.tag;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore encryption failures and fall back to the next candidate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (encryptedContent) {
|
||||
event.content = encryptedContent;
|
||||
if (encryptionTagValue) {
|
||||
event.tags.push(["encrypted", encryptionTagValue]);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus?.({ status: "mute-publishing" });
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
const wrapped =
|
||||
error instanceof Error ? error : new Error(String(error || "mute-signature-failed"));
|
||||
wrapped.code = wrapped.code || "mute-signature-failed";
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const publishResults = await publishEventToRelays(
|
||||
nostrClient.pool,
|
||||
nostrClient.relays,
|
||||
signedEvent,
|
||||
);
|
||||
|
||||
const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" });
|
||||
|
||||
if (summary.failed.length) {
|
||||
summary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[UserBlockList] Mute list not accepted by ${url}: ${reason}`,
|
||||
relayError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.muteEventId = signedEvent.id;
|
||||
this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at)
|
||||
? signedEvent.created_at
|
||||
: event.created_at;
|
||||
|
||||
onStatus?.({ status: "mute-published", event: signedEvent });
|
||||
|
||||
return signedEvent;
|
||||
}
|
||||
|
||||
async publishBlockList(userPubkey, options = {}) {
|
||||
const onStatus =
|
||||
options && typeof options.onStatus === "function" ? options.onStatus : null;
|
||||
@@ -1062,6 +1197,19 @@ class UserBlockListManager {
|
||||
this.lastPublishedCreatedAt = this.blockEventCreatedAt;
|
||||
onStatus?.({ status: "published", event: signedEvent });
|
||||
|
||||
try {
|
||||
await this.publishMuteListSnapshot({
|
||||
signer,
|
||||
ownerPubkey: normalized,
|
||||
blockedPubkeys: payload.blockedPubkeys,
|
||||
createdAt: event.created_at,
|
||||
plaintext,
|
||||
onStatus,
|
||||
});
|
||||
} catch (muteError) {
|
||||
userLogger.warn("[UserBlockList] Failed to publish mute list", muteError);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.loadBlocks(normalized, {
|
||||
since: this.lastPublishedCreatedAt ?? undefined,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
DEFAULT_BLUR_THRESHOLD,
|
||||
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
} from "../../js/constants.js";
|
||||
import { createModerationStage } from "../../js/feedEngine/stages.js";
|
||||
import { withMockedNostrTools, createModerationServiceHarness } from "../helpers/moderation-test-helpers.mjs";
|
||||
|
||||
const { userBlocks } = await import("../../js/userBlocks.js");
|
||||
const { nostrClient } = await import("../../js/nostrClientFacade.js");
|
||||
const { setActiveSigner, clearActiveSigner } = await import("../../js/nostr.js");
|
||||
|
||||
test("trusted mute lists from seeds hide authors for anonymous viewers", async (t) => {
|
||||
withMockedNostrTools(t);
|
||||
|
||||
const seedHex = "1".repeat(64);
|
||||
const mutedHex = "2".repeat(64);
|
||||
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
||||
const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
const originalLoaded = userBlocks.loaded;
|
||||
const originalLoadBlocks = userBlocks.loadBlocks;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
? [...nostrClient.relays]
|
||||
: nostrClient.relays;
|
||||
const originalWriteRelays = Array.isArray(nostrClient.writeRelays)
|
||||
? [...nostrClient.writeRelays]
|
||||
: nostrClient.writeRelays;
|
||||
const originalPool = nostrClient.pool;
|
||||
|
||||
t.after(() => {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
||||
userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
userBlocks.loaded = originalLoaded;
|
||||
userBlocks.loadBlocks = originalLoadBlocks;
|
||||
nostrClient.relays = originalRelays;
|
||||
nostrClient.writeRelays = originalWriteRelays;
|
||||
nostrClient.pool = originalPool;
|
||||
clearActiveSigner();
|
||||
});
|
||||
|
||||
const publishedEvents = [];
|
||||
nostrClient.relays = ["wss://seed-relay.example"];
|
||||
nostrClient.writeRelays = nostrClient.relays;
|
||||
nostrClient.pool = {
|
||||
publish: (_targets, event) => {
|
||||
publishedEvents.push(event);
|
||||
return {
|
||||
on(eventName, handler) {
|
||||
if (eventName === "ok") {
|
||||
setImmediate(handler);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
list: async () => [],
|
||||
};
|
||||
|
||||
userBlocks.loadBlocks = async () => {};
|
||||
userBlocks.blockedPubkeys = new Set();
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.blockEventCreatedAt = null;
|
||||
userBlocks.lastPublishedCreatedAt = null;
|
||||
userBlocks.muteEventId = null;
|
||||
userBlocks.muteEventCreatedAt = null;
|
||||
userBlocks.loaded = true;
|
||||
|
||||
const signedEvents = [];
|
||||
setActiveSigner({
|
||||
type: "private-key",
|
||||
pubkey: seedHex,
|
||||
nip04Encrypt: async (pubkey, plaintext) => {
|
||||
assert.equal(pubkey, seedHex, "nip04 encrypt should target the seed pubkey");
|
||||
return `nip04:${plaintext}`;
|
||||
},
|
||||
nip44Encrypt: async (pubkey, plaintext) => {
|
||||
assert.equal(pubkey, seedHex, "nip44 encrypt should target the seed pubkey");
|
||||
return `nip44:${plaintext}`;
|
||||
},
|
||||
signEvent: async (event) => {
|
||||
const signed = {
|
||||
...event,
|
||||
id: `event-${signedEvents.length + 1}`,
|
||||
created_at: event.created_at ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
signedEvents.push(signed);
|
||||
return signed;
|
||||
},
|
||||
});
|
||||
|
||||
await userBlocks.addBlock(mutedHex, seedHex);
|
||||
|
||||
const muteEvent = signedEvents.find((event) => event.kind === 10000);
|
||||
assert(muteEvent, "block list updates should publish a kind 10000 mute list event");
|
||||
assert(
|
||||
Array.isArray(muteEvent.tags) &&
|
||||
muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === mutedHex),
|
||||
"mute list event should include p-tags for blocked pubkeys",
|
||||
);
|
||||
assert.equal(
|
||||
publishedEvents.filter((event) => event?.kind === 10000).length,
|
||||
1,
|
||||
"mute list event should be published to relays",
|
||||
);
|
||||
|
||||
const { service } = createModerationServiceHarness(t, { userBlocks });
|
||||
service.refreshViewerFromClient = async () => {};
|
||||
service.setActiveEventIds = async () => {};
|
||||
service.subscribeToReports = async () => {};
|
||||
service.teardownReportSubscription = () => {};
|
||||
service.getAdminListSnapshot = () => ({
|
||||
whitelist: new Set(),
|
||||
whitelistHex: new Set(),
|
||||
blacklist: new Set(),
|
||||
blacklistHex: new Set(),
|
||||
});
|
||||
service.getAccessControlStatus = () => ({ whitelisted: false, blacklisted: false, hex: "" });
|
||||
|
||||
service.setTrustedSeeds([seedHex]);
|
||||
service.ingestTrustedMuteEvent(muteEvent);
|
||||
|
||||
assert.equal(
|
||||
service.isAuthorMutedByTrusted(mutedHex),
|
||||
true,
|
||||
"trusted mute ingestion should flag the author as muted",
|
||||
);
|
||||
|
||||
const stage = createModerationStage({
|
||||
service,
|
||||
autoplayThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
|
||||
blurThreshold: DEFAULT_BLUR_THRESHOLD,
|
||||
trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||
reportType: "nudity",
|
||||
});
|
||||
|
||||
const items = [{ video: { id: "video-muted", pubkey: mutedHex }, metadata: {} }];
|
||||
const why = [];
|
||||
const context = {
|
||||
runtime: { isAuthorBlocked: () => false },
|
||||
addWhy(detail) {
|
||||
why.push(detail);
|
||||
return detail;
|
||||
},
|
||||
log() {},
|
||||
};
|
||||
|
||||
const result = await stage(items, context);
|
||||
|
||||
assert.equal(result.length, 0, "trusted mute threshold should hide the muted author");
|
||||
const reasons = why.map((entry) => entry.reason);
|
||||
assert(
|
||||
reasons.includes("trusted-mute"),
|
||||
"moderation stage should record trusted mute metadata",
|
||||
);
|
||||
assert(
|
||||
reasons.includes("trusted-mute-hide"),
|
||||
"moderation stage should record the trusted mute hide reason",
|
||||
);
|
||||
});
|
||||
@@ -236,8 +236,12 @@ try {
|
||||
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
userBlocks.blockedPubkeys = new Set(["abcd".repeat(16)]);
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.muteEventId = null;
|
||||
userBlocks.muteEventCreatedAt = null;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
@@ -273,6 +277,8 @@ try {
|
||||
} finally {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
restoreRelayState(nostrClient, relaySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ await (async () => {
|
||||
: nostrClient.writeRelays;
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
const originalLoaded = userBlocks.loaded;
|
||||
|
||||
const calls = [];
|
||||
@@ -124,6 +126,8 @@ await (async () => {
|
||||
} finally {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
userBlocks.loaded = originalLoaded;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
@@ -154,6 +158,8 @@ await (async () => {
|
||||
const originalBlocked = new Set(userBlocks.blockedPubkeys);
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
const originalLoaded = userBlocks.loaded;
|
||||
|
||||
const calls = [];
|
||||
@@ -277,6 +283,8 @@ await (async () => {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
userBlocks.loaded = originalLoaded;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
@@ -298,6 +306,8 @@ await (async () => {
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
||||
const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
const originalLoaded = userBlocks.loaded;
|
||||
const originalSeedStateCache = userBlocks.seedStateCache;
|
||||
const originalNostrTools = window.NostrTools;
|
||||
@@ -343,6 +353,8 @@ await (async () => {
|
||||
userBlocks.blockEventId = null;
|
||||
userBlocks.blockEventCreatedAt = null;
|
||||
userBlocks.lastPublishedCreatedAt = null;
|
||||
userBlocks.muteEventId = null;
|
||||
userBlocks.muteEventCreatedAt = null;
|
||||
userBlocks.loaded = false;
|
||||
userBlocks.seedStateCache = new Map();
|
||||
|
||||
@@ -435,6 +447,8 @@ await (async () => {
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
||||
userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
userBlocks.loaded = originalLoaded;
|
||||
userBlocks.seedStateCache = originalSeedStateCache;
|
||||
window.NostrTools = originalNostrTools;
|
||||
@@ -450,6 +464,8 @@ await (async () => {
|
||||
const originalBlockEventId = userBlocks.blockEventId;
|
||||
const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt;
|
||||
const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt;
|
||||
const originalMuteEventId = userBlocks.muteEventId;
|
||||
const originalMuteEventCreatedAt = userBlocks.muteEventCreatedAt;
|
||||
const originalLoaded = userBlocks.loaded;
|
||||
const originalPool = nostrClient.pool;
|
||||
const originalRelays = Array.isArray(nostrClient.relays)
|
||||
@@ -478,6 +494,8 @@ await (async () => {
|
||||
[latestCiphertext, JSON.stringify({ blockedPubkeys: [] })],
|
||||
]);
|
||||
|
||||
const signedEvents = [];
|
||||
|
||||
nostrClient.relays = ["wss://direct-signer.example"];
|
||||
nostrClient.writeRelays = nostrClient.relays;
|
||||
nostrClient.pool = {
|
||||
@@ -530,13 +548,19 @@ await (async () => {
|
||||
decryptPayloads.set(cipher, plaintext);
|
||||
return cipher;
|
||||
},
|
||||
nip44Encrypt: async (pubkey, plaintext) => {
|
||||
assert.equal(pubkey, actor, "nip44 encrypt should target the actor pubkey");
|
||||
return `nip44:${plaintext}`;
|
||||
},
|
||||
signEvent: async (event) => {
|
||||
eventCounter += 1;
|
||||
return {
|
||||
const signed = {
|
||||
...event,
|
||||
id: `event-${eventCounter}`,
|
||||
created_at: event.created_at ?? Math.floor(Date.now() / 1000),
|
||||
};
|
||||
signedEvents.push(signed);
|
||||
return signed;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -582,11 +606,21 @@ await (async () => {
|
||||
0,
|
||||
"direct signer flows should never trigger extension permission prompts",
|
||||
);
|
||||
|
||||
const muteEvent = signedEvents.find((event) => event.kind === 10000);
|
||||
assert(muteEvent, "block list updates should publish a kind 10000 mute list event");
|
||||
assert(
|
||||
Array.isArray(muteEvent.tags) &&
|
||||
muteEvent.tags.some((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1] === target),
|
||||
"mute list event should include p-tags for blocked pubkeys",
|
||||
);
|
||||
} finally {
|
||||
userBlocks.blockedPubkeys = originalBlocked;
|
||||
userBlocks.blockEventId = originalBlockEventId;
|
||||
userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt;
|
||||
userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt;
|
||||
userBlocks.muteEventId = originalMuteEventId;
|
||||
userBlocks.muteEventCreatedAt = originalMuteEventCreatedAt;
|
||||
userBlocks.loaded = originalLoaded;
|
||||
nostrClient.pool = originalPool;
|
||||
nostrClient.relays = originalRelays;
|
||||
|
||||
Reference in new issue
Block a user