From 023475843c8512068cd63878090cf241c5c42151 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:30:40 -0400 Subject: [PATCH] Seed shared blocklist for first-time logins --- docs/moderation/README.md | 2 + js/app.js | 35 +++++ js/userBlocks.js | 283 +++++++++++++++++++++++++++++++++++++ tests/user-blocks.test.mjs | 157 ++++++++++++++++++++ 4 files changed, 477 insertions(+) diff --git a/docs/moderation/README.md b/docs/moderation/README.md index f4f924c6..c23dced7 100644 --- a/docs/moderation/README.md +++ b/docs/moderation/README.md @@ -45,6 +45,8 @@ Operators can delegate hard-hide decisions to trusted curators without giving th To add a community list, append a new `a` tag to the super-admin source list and ask the curator to publish their companion `p` list. To remove a list, delete the `a` tag or ask the curator to clear their `p` entries; the client stops ingesting it on the next refresh. +New operators should also note that fresh viewer accounts automatically inherit the merged admin + community blacklist on their first login. The client seeds the user’s personal block list with that baseline once, so moderators can rely on a shared floor while still allowing operators to remove entries locally without having them reappear. + ### Safety & Moderation controls The profile modal now exposes the blur and autoplay thresholds so operators can dial in stricter or more permissive behavior. Enter a non-negative whole number to override the default or leave the field blank to fall back to the baseline values above. Adjustments are stored locally, applied immediately to the active feed, and rehydrate on every load. diff --git a/js/app.js b/js/app.js index 23afb2b2..6a671219 100644 --- a/js/app.js +++ b/js/app.js @@ -3454,6 +3454,41 @@ class Application { await nwcPromise; + try { + await accessControl.ensureReady(); + } catch (error) { + userLogger.error( + "[Application] Failed to refresh admin lists after login:", + error, + ); + } + + if (activePubkey) { + const aggregatedBlacklist = accessControl.getBlacklist(); + try { + await userBlocks.seedWithNpubs( + activePubkey, + Array.isArray(aggregatedBlacklist) ? aggregatedBlacklist : [], + ); + } catch (error) { + if ( + error?.code === "extension-permission-denied" || + error?.code === "nip04-missing" || + error?.name === "RelayPublishError" + ) { + userLogger.error( + "[Application] Failed to seed shared block list after login:", + error, + ); + } else { + devLogger.error( + "[Application] Unexpected error while seeding shared block list:", + error, + ); + } + } + } + try { this.reinitializeVideoListView({ reason: "login", postLoginResult }); } catch (error) { diff --git a/js/userBlocks.js b/js/userBlocks.js index da2d3966..e87788bd 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -63,6 +63,160 @@ const FAST_BLOCKLIST_RELAY_LIMIT = 3; const FAST_BLOCKLIST_TIMEOUT_MS = 2500; const BACKGROUND_BLOCKLIST_TIMEOUT_MS = 6000; +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 decodeNpubToHex(npub) { + if (typeof npub !== "string") { + return null; + } + + const trimmed = npub.trim(); + if (!trimmed) { + return null; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + + if (!trimmed.toLowerCase().startsWith("npub1")) { + return null; + } + + const tools = window?.NostrTools; + const decoder = tools?.nip19?.decode; + if (typeof decoder !== "function") { + return null; + } + + try { + const decoded = decoder(trimmed); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + const hex = decoded.data.trim(); + if (/^[0-9a-f]{64}$/i.test(hex)) { + return hex.toLowerCase(); + } + } + } catch (error) { + return null; + } + + return null; +} + +function readSeededFlag(actorHex) { + if (typeof actorHex !== "string" || !actorHex) { + return false; + } + + if (typeof localStorage === "undefined") { + return false; + } + + const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`; + + try { + const value = localStorage.getItem(key); + return value === "1"; + } catch (error) { + userLogger.warn( + `[UserBlockList] Failed to read seeded baseline state for ${actorHex}:`, + error, + ); + return false; + } +} + +function writeSeededFlag(actorHex, seeded) { + if (typeof actorHex !== "string" || !actorHex) { + return; + } + + if (typeof localStorage === "undefined") { + return; + } + + const key = `${BLOCKLIST_SEEDED_KEY_PREFIX}:${actorHex}`; + + try { + if (seeded) { + localStorage.setItem(key, "1"); + } else { + localStorage.removeItem(key); + } + } catch (error) { + userLogger.warn( + `[UserBlockList] Failed to persist seeded baseline state for ${actorHex}:`, + error, + ); + } +} + +function readRemovalSet(actorHex) { + const empty = new Set(); + if (typeof actorHex !== "string" || !actorHex) { + return empty; + } + + if (typeof localStorage === "undefined") { + return empty; + } + + const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`; + + try { + const raw = localStorage.getItem(key); + if (raw === null) { + return empty; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return empty; + } + + const normalized = parsed + .map((entry) => (typeof entry === "string" ? entry.trim().toLowerCase() : "")) + .filter((entry) => /^[0-9a-f]{64}$/.test(entry)); + + return new Set(normalized); + } catch (error) { + userLogger.warn( + `[UserBlockList] Failed to read seed removal state for ${actorHex}:`, + error, + ); + return empty; + } +} + +function writeRemovalSet(actorHex, removals) { + if (typeof actorHex !== "string" || !actorHex) { + return; + } + + if (typeof localStorage === "undefined") { + return; + } + + const key = `${BLOCKLIST_REMOVALS_KEY_PREFIX}:${actorHex}`; + + try { + if (!removals || !removals.size) { + localStorage.removeItem(key); + return; + } + + localStorage.setItem(key, JSON.stringify(Array.from(removals))); + } catch (error) { + userLogger.warn( + `[UserBlockList] Failed to persist seed removal state for ${actorHex}:`, + error, + ); + } +} + function normalizeHex(pubkey) { if (typeof pubkey !== "string") { return null; @@ -77,6 +231,11 @@ function normalizeHex(pubkey) { return trimmed.toLowerCase(); } + const decoded = decodeNpubToHex(trimmed); + if (decoded) { + return decoded; + } + return null; } @@ -88,6 +247,7 @@ class UserBlockListManager { this.lastPublishedCreatedAt = null; this.loaded = false; this.emitter = new TinyEventEmitter(); + this.seedStateCache = new Map(); } reset() { @@ -540,6 +700,127 @@ class UserBlockListManager { } } + _getSeedState(actorHex) { + const normalized = normalizeHex(actorHex); + if (!normalized) { + return { seeded: false, removals: new Set() }; + } + + if (this.seedStateCache.has(normalized)) { + return this.seedStateCache.get(normalized); + } + + const seeded = readSeededFlag(normalized); + const removals = readRemovalSet(normalized); + const state = { seeded, removals }; + this.seedStateCache.set(normalized, state); + return state; + } + + _setSeeded(actorHex, seeded) { + const normalized = normalizeHex(actorHex); + if (!normalized) { + return; + } + + const state = this._getSeedState(normalized); + state.seeded = Boolean(seeded); + writeSeededFlag(normalized, state.seeded); + } + + _addSeedRemoval(actorHex, targetHex) { + const normalizedActor = normalizeHex(actorHex); + const normalizedTarget = normalizeHex(targetHex); + if (!normalizedActor || !normalizedTarget) { + return; + } + + const state = this._getSeedState(normalizedActor); + if (!state.removals.has(normalizedTarget)) { + state.removals.add(normalizedTarget); + writeRemovalSet(normalizedActor, state.removals); + } + } + + _clearSeedRemoval(actorHex, targetHex) { + const normalizedActor = normalizeHex(actorHex); + const normalizedTarget = normalizeHex(targetHex); + if (!normalizedActor || !normalizedTarget) { + return; + } + + const state = this._getSeedState(normalizedActor); + if (state.removals.delete(normalizedTarget)) { + writeRemovalSet(normalizedActor, state.removals); + } + } + + async seedWithNpubs(userPubkey, candidateNpubs = []) { + const actorHex = normalizeHex(userPubkey); + if (!actorHex) { + return { ok: false, seeded: false, reason: "invalid-user" }; + } + + await this.ensureLoaded(actorHex); + + const state = this._getSeedState(actorHex); + if (state.seeded) { + return { ok: true, seeded: false, reason: "already-seeded" }; + } + + if (this.blockedPubkeys.size > 0) { + return { ok: true, seeded: false, reason: "non-empty" }; + } + + const removals = state.removals; + const additions = new Set(); + + const candidates = Array.isArray(candidateNpubs) ? candidateNpubs : []; + for (const candidate of candidates) { + const candidateHex = normalizeHex(candidate); + if (!candidateHex) { + continue; + } + if (candidateHex === actorHex) { + continue; + } + if (removals.has(candidateHex)) { + continue; + } + additions.add(candidateHex); + } + + if (!additions.size) { + return { ok: true, seeded: false, reason: "no-candidates" }; + } + + const snapshot = new Set(this.blockedPubkeys); + additions.forEach((hex) => this.blockedPubkeys.add(hex)); + + try { + await this.publishBlockList(actorHex); + } catch (error) { + this.blockedPubkeys = snapshot; + throw error; + } + + this._setSeeded(actorHex, true); + additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex)); + + try { + this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { + action: "seed", + actorPubkey: actorHex, + blockedPubkeys: Array.from(this.blockedPubkeys), + addedPubkeys: Array.from(additions), + }); + } catch (error) { + userLogger.warn("[UserBlockList] Failed to emit seed change event:", error); + } + + return { ok: true, seeded: true, addedPubkeys: Array.from(additions) }; + } + async addBlock(targetPubkey, userPubkey) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { @@ -575,6 +856,7 @@ class UserBlockListManager { targetPubkey: targetHex, actorPubkey: actorHex, }); + this._clearSeedRemoval(actorHex, targetHex); return { ok: true }; } catch (err) { this.blockedPubkeys = snapshot; @@ -609,6 +891,7 @@ class UserBlockListManager { targetPubkey: targetHex, actorPubkey: actorHex, }); + this._addSeedRemoval(actorHex, targetHex); return { ok: true }; } catch (err) { this.blockedPubkeys = snapshot; diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index ab379701..ecc6371a 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -269,3 +269,160 @@ await (async () => { window.nostr = originalNostr; } })(); + +await (async () => { + const actor = "1".repeat(64); + const seedOneHex = "2".repeat(64); + const seedTwoHex = "3".repeat(64); + const seedOneNpub = "npub1seedone"; + const seedTwoNpub = "npub1seedtwo"; + + const originalEnsureLoaded = userBlocks.ensureLoaded; + const originalPublishBlockList = userBlocks.publishBlockList; + const originalBlocked = new Set(userBlocks.blockedPubkeys); + const originalBlockEventId = userBlocks.blockEventId; + const originalBlockEventCreatedAt = userBlocks.blockEventCreatedAt; + const originalLastPublishedCreatedAt = userBlocks.lastPublishedCreatedAt; + const originalLoaded = userBlocks.loaded; + const originalSeedStateCache = userBlocks.seedStateCache; + const originalNostrTools = window.NostrTools; + + const publishCalls = []; + + localStorage.clear(); + + const decodeMap = new Map([ + [seedOneNpub, seedOneHex], + [seedTwoNpub, seedTwoHex], + ]); + + window.NostrTools = { + ...(originalNostrTools || {}), + nip19: { + ...((originalNostrTools && originalNostrTools.nip19) || {}), + decode(value) { + if (decodeMap.has(value)) { + return { type: "npub", data: decodeMap.get(value) }; + } + throw new Error(`unexpected npub decode: ${value}`); + }, + }, + }; + + userBlocks.ensureLoaded = async () => { + userBlocks.loaded = true; + }; + + userBlocks.publishBlockList = async (pubkey) => { + publishCalls.push({ + pubkey, + blocked: Array.from(userBlocks.blockedPubkeys), + }); + userBlocks.blockEventId = `event-${publishCalls.length}`; + userBlocks.blockEventCreatedAt = 1_000 + publishCalls.length; + userBlocks.lastPublishedCreatedAt = userBlocks.blockEventCreatedAt; + return { id: userBlocks.blockEventId, created_at: userBlocks.blockEventCreatedAt }; + }; + + userBlocks.blockedPubkeys = new Set(); + userBlocks.blockEventId = null; + userBlocks.blockEventCreatedAt = null; + userBlocks.lastPublishedCreatedAt = null; + userBlocks.loaded = false; + userBlocks.seedStateCache = new Map(); + + try { + const seedResult = await userBlocks.seedWithNpubs(actor, [ + seedOneNpub, + seedTwoNpub, + seedTwoNpub, + ]); + + assert.equal( + seedResult.seeded, + true, + "initial seeding should publish the aggregated blacklist", + ); + assert.equal( + publishCalls.length, + 1, + "seeding should publish exactly one block list event", + ); + assert.deepEqual( + Array.from(userBlocks.blockedPubkeys).sort(), + [seedOneHex, seedTwoHex], + "seeding should populate the local block list with decoded hex pubkeys", + ); + + const secondSeed = await userBlocks.seedWithNpubs(actor, [seedOneNpub, seedTwoNpub]); + assert.equal( + secondSeed.seeded, + false, + "subsequent seeding attempts should be a no-op", + ); + assert.equal( + publishCalls.length, + 1, + "subsequent seeding attempts should not republish the block list", + ); + + await userBlocks.removeBlock(seedTwoHex, actor); + assert.equal( + publishCalls.length, + 2, + "removing a seeded pubkey should republish the block list", + ); + assert.deepEqual( + Array.from(userBlocks.blockedPubkeys), + [seedOneHex], + "removal should persist in the local block list", + ); + + await userBlocks.removeBlock(seedOneHex, actor); + assert.equal( + publishCalls.length, + 3, + "removing the final baseline entry should publish again", + ); + assert.equal( + userBlocks.blockedPubkeys.size, + 0, + "block list should become empty after removing all seeded entries", + ); + + const removalState = userBlocks._getSeedState(actor); + assert(removalState.removals.has(seedOneHex), "seed removals should be recorded for the actor"); + assert(removalState.removals.has(seedTwoHex), "seed removals should include every removed pubkey"); + + const afterRemovalSeed = await userBlocks.seedWithNpubs(actor, [ + seedOneNpub, + seedTwoNpub, + ]); + assert.equal( + afterRemovalSeed.seeded, + false, + "seeding should stay disabled after removals", + ); + assert.equal( + publishCalls.length, + 3, + "no additional publish calls should occur after removals", + ); + assert.equal( + userBlocks.blockedPubkeys.size, + 0, + "removed entries should not be re-applied by seeding", + ); + } finally { + userBlocks.ensureLoaded = originalEnsureLoaded; + userBlocks.publishBlockList = originalPublishBlockList; + userBlocks.blockedPubkeys = originalBlocked; + userBlocks.blockEventId = originalBlockEventId; + userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; + userBlocks.lastPublishedCreatedAt = originalLastPublishedCreatedAt; + userBlocks.loaded = originalLoaded; + userBlocks.seedStateCache = originalSeedStateCache; + window.NostrTools = originalNostrTools; + localStorage.clear(); + } +})();