mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 21:02:28 +00:00
Harden moderation trust handling
This commit is contained in:
1 parent
0aa28a023d
commit
397ed7c8ce
5 files changed
+661
-8
No files matched your search
+131
-2
@@ -88,6 +88,7 @@ export function createBlacklistFilterStage({
|
||||
: () => false;
|
||||
|
||||
const options = { blacklistedEventIds: blacklist, isAuthorBlocked };
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
@@ -245,6 +246,85 @@ export function createModerationStage({
|
||||
context?.log?.(`[${stageName}] Failed to prime report subscriptions`, error);
|
||||
}
|
||||
|
||||
const toStringSet = (input, { lowercase = false } = {}) => {
|
||||
const output = new Set();
|
||||
if (input instanceof Set) {
|
||||
for (const value of input) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
output.add(lowercase ? trimmed.toLowerCase() : trimmed);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
if (Array.isArray(input)) {
|
||||
for (const value of input) {
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
output.add(lowercase ? trimmed.toLowerCase() : trimmed);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
let adminSnapshot = {
|
||||
whitelist: new Set(),
|
||||
whitelistHex: new Set(),
|
||||
blacklist: new Set(),
|
||||
blacklistHex: new Set(),
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof resolvedService.getAdminListSnapshot === "function") {
|
||||
const snapshot = resolvedService.getAdminListSnapshot();
|
||||
if (snapshot && typeof snapshot === "object") {
|
||||
adminSnapshot = {
|
||||
whitelist: toStringSet(snapshot.whitelist),
|
||||
whitelistHex: toStringSet(snapshot.whitelistHex, { lowercase: true }),
|
||||
blacklist: toStringSet(snapshot.blacklist),
|
||||
blacklistHex: toStringSet(snapshot.blacklistHex, { lowercase: true }),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] Failed to load admin list snapshot`, error);
|
||||
}
|
||||
|
||||
const resolveAdminStatus =
|
||||
typeof resolvedService.getAccessControlStatus === "function"
|
||||
? (identifier) => {
|
||||
try {
|
||||
return (
|
||||
resolvedService.getAccessControlStatus(identifier, adminSnapshot) || {
|
||||
hex: "",
|
||||
whitelisted: false,
|
||||
blacklisted: false,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] getAccessControlStatus threw`, error);
|
||||
return { hex: "", whitelisted: false, blacklisted: false };
|
||||
}
|
||||
}
|
||||
: () => ({ hex: "", whitelisted: false, blacklisted: false });
|
||||
|
||||
const feedName =
|
||||
typeof context?.feedName === "string" ? context.feedName.trim().toLowerCase() : "";
|
||||
const runtimeVariant =
|
||||
typeof context?.runtime?.feedVariant === "string"
|
||||
? context.runtime.feedVariant.trim().toLowerCase()
|
||||
: "";
|
||||
const isDiscoveryFeed = feedName === "discovery" || runtimeVariant === "discovery";
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const item of items) {
|
||||
@@ -260,6 +340,46 @@ export function createModerationStage({
|
||||
}
|
||||
|
||||
const videoId = typeof video.id === "string" ? video.id : "";
|
||||
const authorPubkey = typeof video.pubkey === "string" ? video.pubkey : "";
|
||||
|
||||
let viewerBlocked = false;
|
||||
if (authorPubkey && typeof context?.runtime?.isAuthorBlocked === "function") {
|
||||
try {
|
||||
viewerBlocked = !!context.runtime.isAuthorBlocked(authorPubkey);
|
||||
} catch (error) {
|
||||
context?.log?.(`[${stageName}] isAuthorBlocked threw`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const adminStatus = resolveAdminStatus(authorPubkey);
|
||||
const authorHex =
|
||||
(adminStatus?.hex && typeof adminStatus.hex === "string"
|
||||
? adminStatus.hex
|
||||
: authorPubkey)
|
||||
?.trim()
|
||||
.toLowerCase() || "";
|
||||
|
||||
if (viewerBlocked) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "viewer-block",
|
||||
videoId: videoId || null,
|
||||
pubkey: authorHex || authorPubkey || null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (adminStatus?.blacklisted) {
|
||||
context?.addWhy?.({
|
||||
stage: stageName,
|
||||
type: "filter",
|
||||
reason: "admin-blacklist",
|
||||
videoId: videoId || null,
|
||||
pubkey: authorHex || authorPubkey || null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let summary = null;
|
||||
let trustedCount = 0;
|
||||
@@ -303,8 +423,13 @@ export function createModerationStage({
|
||||
}
|
||||
}
|
||||
|
||||
const blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
|
||||
const blurThumbnail = trustedCount >= normalizedBlurThreshold;
|
||||
let blockAutoplay = trustedCount >= normalizedAutoplayThreshold;
|
||||
let blurThumbnail = trustedCount >= normalizedBlurThreshold;
|
||||
const adminWhitelistBypass = isDiscoveryFeed && adminStatus?.whitelisted === true;
|
||||
if (adminWhitelistBypass) {
|
||||
blockAutoplay = false;
|
||||
blurThumbnail = false;
|
||||
}
|
||||
|
||||
if (!item.metadata || typeof item.metadata !== "object") {
|
||||
item.metadata = {};
|
||||
@@ -323,6 +448,8 @@ export function createModerationStage({
|
||||
metadataModeration.trustedReporters = Array.isArray(trustedReporters)
|
||||
? trustedReporters.slice()
|
||||
: [];
|
||||
metadataModeration.adminWhitelist = adminStatus?.whitelisted === true;
|
||||
metadataModeration.adminWhitelistBypass = adminWhitelistBypass;
|
||||
item.metadata.moderation = metadataModeration;
|
||||
|
||||
if (!video.moderation || typeof video.moderation !== "object") {
|
||||
@@ -333,6 +460,8 @@ export function createModerationStage({
|
||||
video.moderation.blurThumbnail = blurThumbnail;
|
||||
video.moderation.trustedCount = trustedCount;
|
||||
video.moderation.reportType = normalizedReportType;
|
||||
video.moderation.adminWhitelist = adminStatus?.whitelisted === true;
|
||||
video.moderation.adminWhitelistBypass = adminWhitelistBypass;
|
||||
if (Array.isArray(trustedReporters) && trustedReporters.length) {
|
||||
video.moderation.trustedReporters = trustedReporters.slice();
|
||||
} else if (video.moderation.trustedReporters) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { nostrClient } from "../nostr.js";
|
||||
import { publishEventToRelays, assertAnyRelayAccepted } from "../nostrPublish.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { userBlocks } from "../userBlocks.js";
|
||||
import logger from "../utils/logger.js";
|
||||
|
||||
class SimpleEventEmitter {
|
||||
@@ -115,6 +117,97 @@ function cloneSummary(summary) {
|
||||
};
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
if (typeof window !== "undefined" && window?.NostrTools) {
|
||||
return window.NostrTools;
|
||||
}
|
||||
if (typeof globalThis !== "undefined" && globalThis?.NostrTools) {
|
||||
return globalThis.NostrTools;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes) {
|
||||
if (!bytes || typeof bytes.length !== "number") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let hex = "";
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
const value = bytes[index];
|
||||
if (typeof value !== "number") {
|
||||
return "";
|
||||
}
|
||||
const normalized = value & 0xff;
|
||||
hex += normalized.toString(16).padStart(2, "0");
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function decodeToHex(candidate) {
|
||||
if (typeof candidate !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const tools = getNostrTools();
|
||||
const decoder = tools?.nip19?.decode;
|
||||
if (typeof decoder !== "function") {
|
||||
return "";
|
||||
}
|
||||
const decoded = decoder(trimmed);
|
||||
if (!decoded || decoded.type !== "npub") {
|
||||
return "";
|
||||
}
|
||||
const data = decoded.data;
|
||||
if (typeof data === "string") {
|
||||
return normalizeHex(data);
|
||||
}
|
||||
return bytesToHex(data);
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function encodeToNpub(hex) {
|
||||
const normalized = normalizeHex(hex);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const tools = getNostrTools();
|
||||
const encoder = tools?.nip19?.npubEncode;
|
||||
if (typeof encoder !== "function") {
|
||||
return "";
|
||||
}
|
||||
return encoder(normalized) || "";
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToHex(candidate) {
|
||||
const direct = normalizeHex(candidate);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return decodeToHex(candidate);
|
||||
}
|
||||
|
||||
function createEmptyAdminSnapshot() {
|
||||
return {
|
||||
whitelist: new Set(),
|
||||
whitelistHex: new Set(),
|
||||
blacklist: new Set(),
|
||||
blacklistHex: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelayList(client, { write = false } = {}) {
|
||||
if (!client) {
|
||||
return [];
|
||||
@@ -212,12 +305,19 @@ function ensureNumber(value) {
|
||||
}
|
||||
|
||||
export class ModerationService {
|
||||
constructor({ nostrClient: client = null, logger: log = null } = {}) {
|
||||
constructor({
|
||||
nostrClient: client = null,
|
||||
logger: log = null,
|
||||
userBlocks: userBlockManager = null,
|
||||
accessControl: accessControlService = null,
|
||||
} = {}) {
|
||||
this.nostrClient = client;
|
||||
this.log = normalizeLogger(log);
|
||||
|
||||
this.viewerPubkey = "";
|
||||
this.trustedContacts = new Set();
|
||||
this.userBlocks = userBlockManager;
|
||||
this.accessControl = accessControlService;
|
||||
|
||||
this.contactSubscription = null;
|
||||
this.contactListPromise = null;
|
||||
@@ -246,6 +346,18 @@ export class ModerationService {
|
||||
}
|
||||
}
|
||||
|
||||
setUserBlocks(manager) {
|
||||
if (manager && manager !== this.userBlocks) {
|
||||
this.userBlocks = manager;
|
||||
}
|
||||
}
|
||||
|
||||
setAccessControl(control) {
|
||||
if (control && control !== this.accessControl) {
|
||||
this.accessControl = control;
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName, handler) {
|
||||
return this.emitter.on(eventName, handler);
|
||||
}
|
||||
@@ -254,6 +366,167 @@ export class ModerationService {
|
||||
this.emitter.emit(eventName, detail);
|
||||
}
|
||||
|
||||
async ensureUserBlocksLoaded(pubkey) {
|
||||
if (!this.userBlocks || typeof this.userBlocks.ensureLoaded !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.userBlocks.ensureLoaded(normalized);
|
||||
} catch (error) {
|
||||
this.log("[moderationService] failed to load user block list", error);
|
||||
}
|
||||
}
|
||||
|
||||
isPubkeyBlockedByViewer(pubkey) {
|
||||
if (!this.userBlocks || typeof this.userBlocks.isBlocked !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizeHex(pubkey);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return this.userBlocks.isBlocked(normalized) === true;
|
||||
} catch (error) {
|
||||
this.log("[moderationService] user block lookup failed", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getAdminListSnapshot() {
|
||||
const snapshot = createEmptyAdminSnapshot();
|
||||
|
||||
if (!this.accessControl) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
let whitelist = [];
|
||||
let blacklist = [];
|
||||
|
||||
if (typeof this.accessControl.getWhitelist === "function") {
|
||||
try {
|
||||
whitelist = this.accessControl.getWhitelist() || [];
|
||||
} catch (error) {
|
||||
this.log("[moderationService] accessControl.getWhitelist threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof this.accessControl.getBlacklist === "function") {
|
||||
try {
|
||||
blacklist = this.accessControl.getBlacklist() || [];
|
||||
} catch (error) {
|
||||
this.log("[moderationService] accessControl.getBlacklist threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of whitelist) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
snapshot.whitelist.add(trimmed);
|
||||
const decoded = decodeToHex(trimmed);
|
||||
if (decoded) {
|
||||
snapshot.whitelistHex.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of blacklist) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
snapshot.blacklist.add(trimmed);
|
||||
const decoded = decodeToHex(trimmed);
|
||||
if (decoded) {
|
||||
snapshot.blacklistHex.add(decoded);
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getAccessControlStatus(candidate, snapshot = null) {
|
||||
const resolvedSnapshot = snapshot && typeof snapshot === "object" ? snapshot : this.getAdminListSnapshot();
|
||||
const whitelist =
|
||||
resolvedSnapshot?.whitelist instanceof Set
|
||||
? resolvedSnapshot.whitelist
|
||||
: new Set();
|
||||
const whitelistHex =
|
||||
resolvedSnapshot?.whitelistHex instanceof Set
|
||||
? resolvedSnapshot.whitelistHex
|
||||
: new Set();
|
||||
const blacklist =
|
||||
resolvedSnapshot?.blacklist instanceof Set
|
||||
? resolvedSnapshot.blacklist
|
||||
: new Set();
|
||||
const blacklistHex =
|
||||
resolvedSnapshot?.blacklistHex instanceof Set
|
||||
? resolvedSnapshot.blacklistHex
|
||||
: new Set();
|
||||
|
||||
const hex = normalizeToHex(candidate);
|
||||
const status = {
|
||||
hex,
|
||||
whitelisted: false,
|
||||
blacklisted: false,
|
||||
npub: "",
|
||||
};
|
||||
|
||||
if (hex) {
|
||||
if (blacklistHex.has(hex)) {
|
||||
status.blacklisted = true;
|
||||
return status;
|
||||
}
|
||||
if (whitelistHex.has(hex)) {
|
||||
status.whitelisted = true;
|
||||
status.npub = encodeToNpub(hex) || "";
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
const encoded = hex ? encodeToNpub(hex) : "";
|
||||
if (encoded) {
|
||||
status.npub = encoded;
|
||||
if (blacklist.has(encoded)) {
|
||||
status.blacklisted = true;
|
||||
return status;
|
||||
}
|
||||
if (whitelist.has(encoded)) {
|
||||
status.whitelisted = true;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate === "string" && !status.npub) {
|
||||
const trimmed = candidate.trim();
|
||||
if (trimmed && (blacklist.has(trimmed) || whitelist.has(trimmed))) {
|
||||
status.npub = trimmed;
|
||||
if (blacklist.has(trimmed)) {
|
||||
status.blacklisted = true;
|
||||
} else if (whitelist.has(trimmed)) {
|
||||
status.whitelisted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
async ensurePool() {
|
||||
if (!this.nostrClient || typeof this.nostrClient.ensurePool !== "function") {
|
||||
throw new Error("nostr-client-unavailable");
|
||||
@@ -284,6 +557,7 @@ export class ModerationService {
|
||||
} catch (error) {
|
||||
this.log("[moderationService] contact hydration failed", error);
|
||||
}
|
||||
await this.ensureUserBlocksLoaded(normalized);
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
@@ -301,6 +575,7 @@ export class ModerationService {
|
||||
this.contactSubscription = null;
|
||||
|
||||
if (!normalized) {
|
||||
await this.ensureUserBlocksLoaded(normalized);
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
@@ -323,6 +598,7 @@ export class ModerationService {
|
||||
this.log("[moderationService] failed to subscribe to contact list", error);
|
||||
});
|
||||
|
||||
await this.ensureUserBlocksLoaded(normalized);
|
||||
return this.viewerPubkey;
|
||||
}
|
||||
|
||||
@@ -611,9 +887,25 @@ export class ModerationService {
|
||||
|
||||
const typeStats = new Map();
|
||||
let totalTrusted = 0;
|
||||
const adminSnapshot = this.getAdminListSnapshot();
|
||||
|
||||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||||
const isTrusted = this.trustedContacts.has(reporter);
|
||||
const status = this.getAccessControlStatus(reporter, adminSnapshot);
|
||||
const reporterHex = status.hex || normalizeHex(reporter);
|
||||
if (!reporterHex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isPubkeyBlockedByViewer(reporterHex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.blacklisted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
|
||||
for (const [type, detail] of typeMap.entries()) {
|
||||
let stats = typeStats.get(type);
|
||||
if (!stats) {
|
||||
@@ -625,7 +917,7 @@ export class ModerationService {
|
||||
if (createdAt > stats.latest) {
|
||||
stats.latest = createdAt;
|
||||
}
|
||||
if (isTrusted) {
|
||||
if (isTrustedReporter) {
|
||||
stats.trusted += 1;
|
||||
totalTrusted += 1;
|
||||
}
|
||||
@@ -678,8 +970,25 @@ export class ModerationService {
|
||||
const normalizedType = normalizeReportType(type);
|
||||
const results = [];
|
||||
|
||||
const adminSnapshot = this.getAdminListSnapshot();
|
||||
|
||||
for (const [reporter, typeMap] of eventReports.entries()) {
|
||||
if (!this.trustedContacts.has(reporter)) {
|
||||
const status = this.getAccessControlStatus(reporter, adminSnapshot);
|
||||
const reporterHex = status.hex || normalizeHex(reporter);
|
||||
if (!reporterHex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isPubkeyBlockedByViewer(reporterHex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status.blacklisted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrustedReporter = status.whitelisted || this.trustedContacts.has(reporterHex);
|
||||
if (!isTrustedReporter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -689,7 +998,7 @@ export class ModerationService {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
pubkey: reporter,
|
||||
pubkey: reporterHex,
|
||||
latest: ensureNumber(detail?.created_at),
|
||||
});
|
||||
continue;
|
||||
@@ -702,7 +1011,7 @@ export class ModerationService {
|
||||
latest = candidate;
|
||||
}
|
||||
}
|
||||
results.push({ pubkey: reporter, latest });
|
||||
results.push({ pubkey: reporterHex, latest });
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
@@ -831,6 +1140,8 @@ export class ModerationService {
|
||||
const moderationService = new ModerationService({
|
||||
nostrClient,
|
||||
logger,
|
||||
userBlocks,
|
||||
accessControl,
|
||||
});
|
||||
|
||||
export default moderationService;
|
||||
Reference in new issue
Block a user