diff --git a/components/profile-modal.html b/components/profile-modal.html index dc4c80e9..937467e4 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -240,18 +240,6 @@

Whitelist

-

Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist. diff --git a/js/accessControl.js b/js/accessControl.js index 6e7c40ee..5dea0ae4 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -7,39 +7,7 @@ import { setWhitelistMode as persistWhitelistMode, ADMIN_WHITELIST_MODE_STORAGE_KEY, } from "./config.js"; -import { - ADMIN_INITIAL_BLACKLIST, - ADMIN_INITIAL_WHITELIST, -} from "./lists.js"; - -const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; -const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; -const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; - -const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; -const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; - -function loadJSONList(key) { - try { - const raw = localStorage.getItem(key); - if (raw === null) { - return null; - } - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : null; - } catch (error) { - console.warn(`Failed to parse list for ${key}:`, error); - return null; - } -} - -function saveJSONList(key, values) { - try { - localStorage.setItem(key, JSON.stringify(values)); - } catch (error) { - console.warn(`Failed to persist list for ${key}:`, error); - } -} +import { loadAdminState, persistAdminState } from "./adminListStore.js"; function normalizeNpub(value) { return typeof value === "string" ? value.trim() : ""; @@ -57,26 +25,6 @@ function dedupeNpubs(values) { ); } -function migrateLegacyList(targetKey, legacyKey, fallback) { - const stored = loadJSONList(targetKey); - if (stored !== null) { - return dedupeNpubs(stored); - } - - const legacy = legacyKey ? loadJSONList(legacyKey) : null; - if (legacy !== null) { - const sanitized = dedupeNpubs(legacy); - saveJSONList(targetKey, sanitized); - return sanitized; - } - - const sanitizedFallback = dedupeNpubs(fallback); - if (sanitizedFallback.length) { - saveJSONList(targetKey, sanitizedFallback); - } - return sanitizedFallback; -} - function isValidNpub(npub) { if (typeof npub !== "string") { return false; @@ -100,30 +48,94 @@ class AccessControl { this.whitelist = new Set(); this.blacklist = new Set(); this.whitelistEnabled = getWhitelistMode(); - this.refresh(); + this.hasLoaded = false; + this.lastError = null; + this._isRefreshing = false; + this._refreshPromise = this._performRefresh(); + this._refreshPromise.catch((error) => { + console.error("Failed to load admin lists:", error); + }); + } + + async _performRefresh() { + if (this._isRefreshing) { + return this._refreshPromise; + } + + this._isRefreshing = true; + try { + const state = await loadAdminState(); + const editors = Array.isArray(state?.editors) ? state.editors : []; + const whitelist = Array.isArray(state?.whitelist) + ? state.whitelist + : []; + const blacklist = Array.isArray(state?.blacklist) + ? state.blacklist + : []; + + this.editors = new Set( + dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors]) + ); + const normalizedWhitelist = dedupeNpubs(whitelist); + this.whitelist = new Set(normalizedWhitelist); + + const blacklistDedupe = dedupeNpubs(blacklist); + const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub)); + const adminGuardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...Array.from(this.editors), + ]); + const sanitizedBlacklist = blacklistDedupe.filter((npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (adminGuardSet.has(normalized)) { + return false; + } + return true; + }); + this.blacklist = new Set(sanitizedBlacklist); + + this.whitelistEnabled = getWhitelistMode(); + this.hasLoaded = true; + this.lastError = null; + } catch (error) { + this.lastError = error; + if (!this.hasLoaded) { + this.editors.clear(); + this.whitelist.clear(); + this.blacklist.clear(); + } + throw error; + } finally { + this._isRefreshing = false; + } } refresh() { - this.editors = new Set( - dedupeNpubs([ - ...ADMIN_EDITORS_NPUBS, - ...migrateLegacyList(ADMIN_EDITORS_KEY, null, []), - ]) - ); + const promise = this._performRefresh(); + this._refreshPromise = promise; + promise.catch((error) => { + console.error("Failed to refresh admin lists:", error); + }); + return promise; + } - this.whitelist = new Set( - migrateLegacyList(ADMIN_WHITELIST_KEY, LEGACY_WHITELIST_KEY, [ - ...ADMIN_INITIAL_WHITELIST, - ]) - ); - - this.blacklist = new Set( - migrateLegacyList(ADMIN_BLACKLIST_KEY, LEGACY_BLACKLIST_KEY, [ - ...ADMIN_INITIAL_BLACKLIST, - ]) - ); - - this.whitelistEnabled = getWhitelistMode(); + async ensureReady() { + try { + await this._refreshPromise; + } catch (error) { + if (!this.hasLoaded) { + await this.refresh(); + await this._refreshPromise; + } else { + throw error; + } + } } whitelistMode() { @@ -162,7 +174,7 @@ class AccessControl { return Array.from(this.editors); } - addModerator(requestorNpub, moderatorNpub) { + async addModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -175,13 +187,18 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.editors.add(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + const nextEditors = dedupeNpubs([...this.getEditors(), normalized]); + + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeModerator(requestorNpub, moderatorNpub) { + async removeModerator(requestorNpub, moderatorNpub) { if (!this.isSuperAdmin(requestorNpub)) { return { ok: false, error: "forbidden" }; } @@ -190,14 +207,18 @@ class AccessControl { if (!normalized || normalized === ADMIN_SUPER_NPUB) { return { ok: false, error: "immutable" }; } + const nextEditors = this.getEditors().filter((value) => value !== normalized); - this.editors.delete(normalized); - saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(requestorNpub, { editors: nextEditors }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToWhitelist(actorNpub, targetNpub) { + async addToWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -210,15 +231,24 @@ class AccessControl { return { ok: false, error: "invalid npub" }; } - this.blacklist.delete(normalized); - this.whitelist.add(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]); + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + whitelist: nextWhitelist, + blacklist: nextBlacklist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromWhitelist(actorNpub, targetNpub) { + async removeFromWhitelist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -227,14 +257,20 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); - this.whitelist.delete(normalized); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { whitelist: nextWhitelist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - addToBlacklist(actorNpub, targetNpub) { + async addToBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -251,15 +287,24 @@ class AccessControl { return { ok: false, error: "immutable" }; } - this.whitelist.delete(normalized); - this.blacklist.add(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); - this.refresh(); - return { ok: true }; + const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]); + const nextWhitelist = this.getWhitelist().filter( + (value) => value !== normalized + ); + + try { + await persistAdminState(actorNpub, { + blacklist: nextBlacklist, + whitelist: nextWhitelist, + }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } - removeFromBlacklist(actorNpub, targetNpub) { + async removeFromBlacklist(actorNpub, targetNpub) { if (!this.canEditAdminLists(actorNpub)) { return { ok: false, error: "forbidden" }; } @@ -268,11 +313,17 @@ class AccessControl { if (!normalized) { return { ok: false, error: "invalid npub" }; } + const nextBlacklist = this.getBlacklist().filter( + (value) => value !== normalized + ); - this.blacklist.delete(normalized); - saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); - this.refresh(); - return { ok: true }; + try { + await persistAdminState(actorNpub, { blacklist: nextBlacklist }); + await this.refresh(); + return { ok: true }; + } catch (error) { + return { ok: false, error: error?.code || "storage-error" }; + } } setWhitelistMode(actorNpub, enabled) { @@ -286,7 +337,13 @@ class AccessControl { isBlacklisted(npub) { const normalized = normalizeNpub(npub); - return normalized ? this.blacklist.has(normalized) : false; + if (!normalized) { + return false; + } + if (this.whitelist.has(normalized)) { + return false; + } + return this.blacklist.has(normalized); } canAccess(candidate) { @@ -311,6 +368,14 @@ class AccessControl { return false; } + if (this.isAdminEditor(normalized)) { + return true; + } + + if (this.whitelist.has(normalized)) { + return true; + } + if (this.blacklist.has(normalized)) { return false; } @@ -325,9 +390,6 @@ class AccessControl { export const accessControl = new AccessControl(); export { - ADMIN_EDITORS_KEY, - ADMIN_WHITELIST_KEY, - ADMIN_BLACKLIST_KEY, ADMIN_WHITELIST_MODE_STORAGE_KEY, normalizeNpub, isValidNpub, diff --git a/js/adminListStore.js b/js/adminListStore.js new file mode 100644 index 00000000..706f893e --- /dev/null +++ b/js/adminListStore.js @@ -0,0 +1,553 @@ +// js/adminListStore.js + +import { + ADMIN_LIST_MODE, + ADMIN_LIST_NAMESPACE, + ADMIN_SUPER_NPUB, + ADMIN_EDITORS_NPUBS, + isDevMode, +} from "./config.js"; +import { nostrClient } from "./nostr.js"; + +const LIST_IDENTIFIERS = { + editors: "editors", + whitelist: "whitelist", + blacklist: "blacklist", +}; + +const LEGACY_STORAGE_KEYS = { + editors: "bitvid_admin_editors", + whitelist: "bitvid_admin_whitelist", + whitelistLegacy: "bitvid_whitelist", + blacklist: "bitvid_admin_blacklist", + blacklistLegacy: "bitvid_blacklist", +}; + +const PUBLISH_TIMEOUT_MS = 7000; + +function createError(code, message, cause) { + const error = new Error(message); + error.code = code; + if (cause) { + error.cause = cause; + } + return error; +} + +function normalizeNpub(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function dedupeNpubs(values) { + const normalized = Array.isArray(values) ? values.map(normalizeNpub) : []; + return Array.from( + normalized.reduce((set, npub) => { + if (npub) { + set.add(npub); + } + return set; + }, new Set()) + ); +} + +function canDecodeNpub() { + return !!( + typeof window !== "undefined" && + window?.NostrTools?.nip19 && + typeof window.NostrTools.nip19.decode === "function" + ); +} + +function isLikelyNpub(value) { + if (typeof value !== "string") { + return false; + } + + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + + if (!canDecodeNpub()) { + return true; + } + + try { + const decoded = window.NostrTools.nip19.decode(trimmed); + return decoded?.type === "npub"; + } catch (error) { + return false; + } +} + +function sanitizeNpubList(values) { + return dedupeNpubs(values).filter(isLikelyNpub); +} + +function sanitizeAdminState(state = {}) { + const sanitizedEditors = sanitizeNpubList(state.editors || []); + const sanitizedWhitelist = sanitizeNpubList(state.whitelist || []); + const whitelistSet = new Set(sanitizedWhitelist.map(normalizeNpub)); + + const adminGuardSet = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), + ...sanitizedEditors.map(normalizeNpub), + ]); + + const sanitizedBlacklist = sanitizeNpubList(state.blacklist || []).filter( + (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (adminGuardSet.has(normalized)) { + return false; + } + return true; + } + ); + + return { + editors: sanitizedEditors, + whitelist: sanitizedWhitelist, + blacklist: sanitizedBlacklist, + }; +} + +function hasAnyEntries(state = {}) { + return Boolean( + (Array.isArray(state.editors) && state.editors.length) || + (Array.isArray(state.whitelist) && state.whitelist.length) || + (Array.isArray(state.blacklist) && state.blacklist.length) + ); +} + +function readJsonListFromStorage(key) { + if (typeof localStorage === "undefined") { + return null; + } + + try { + const raw = localStorage.getItem(key); + if (raw === null) { + return null; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch (error) { + if (isDevMode) { + console.warn(`[adminListStore] Failed to parse legacy list for ${key}:`, error); + } + return null; + } +} + +function loadLegacyAdminState() { + const editors = readJsonListFromStorage(LEGACY_STORAGE_KEYS.editors) || []; + const whitelist = + readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelist) || + readJsonListFromStorage(LEGACY_STORAGE_KEYS.whitelistLegacy) || + []; + const blacklist = + readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklist) || + readJsonListFromStorage(LEGACY_STORAGE_KEYS.blacklistLegacy) || + []; + + const sanitized = sanitizeAdminState({ editors, whitelist, blacklist }); + return hasAnyEntries(sanitized) ? sanitized : null; +} + +function clearLegacyStorageFor(listKey) { + if (typeof localStorage === "undefined") { + return; + } + + const keyGroups = { + editors: [LEGACY_STORAGE_KEYS.editors], + whitelist: [ + LEGACY_STORAGE_KEYS.whitelist, + LEGACY_STORAGE_KEYS.whitelistLegacy, + ], + blacklist: [ + LEGACY_STORAGE_KEYS.blacklist, + LEGACY_STORAGE_KEYS.blacklistLegacy, + ], + }; + + const keys = keyGroups[listKey]; + if (!Array.isArray(keys)) { + return; + } + + for (const key of keys) { + try { + localStorage.removeItem(key); + } catch (error) { + if (isDevMode) { + console.warn(`[adminListStore] Failed to clear legacy storage for ${key}:`, error); + } + } + } +} + +function ensureNostrReady() { + if (!nostrClient || !nostrClient.pool) { + throw createError( + "nostr-unavailable", + "Nostr relay pool is not initialized." + ); + } + + const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : []; + if (!relays.length) { + throw createError("nostr-unavailable", "No Nostr relays configured."); + } + + return relays; +} + +function decodeNpubToHex(npub) { + const trimmed = normalizeNpub(npub); + if (!trimmed) { + throw createError("invalid npub", "Empty npub provided."); + } + + try { + const decoded = window?.NostrTools?.nip19?.decode(trimmed); + if (decoded?.type !== "npub" || !decoded?.data) { + throw new Error("Invalid npub format"); + } + return typeof decoded.data === "string" + ? decoded.data + : decoded.data?.pubkey || ""; + } catch (error) { + throw createError("invalid npub", "Unable to decode npub.", error); + } +} + +function encodeHexToNpub(hex) { + try { + return window?.NostrTools?.nip19?.npubEncode(hex) || ""; + } catch (error) { + if (isDevMode) { + console.warn("Failed to encode hex pubkey to npub:", error); + } + return ""; + } +} + +function extractNpubsFromEvent(event) { + if (!event || !Array.isArray(event.tags)) { + return []; + } + + const hexKeys = event.tags + .filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + + const npubs = hexKeys + .map((hex) => encodeHexToNpub(hex)) + .filter((npub) => typeof npub === "string" && npub); + + return dedupeNpubs(npubs); +} + +async function loadNostrList(identifier) { + const relays = ensureNostrReady(); + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + const filter = { + kinds: [30000], + "#d": [dTagValue], + limit: 50, + }; + + let events = []; + try { + const combined = await nostrClient.pool.list(relays, [filter]); + if (Array.isArray(combined)) { + events = combined; + } + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Combined relay fetch failed for ${identifier}:`, + error + ); + } + } + + if (!events.length) { + const perRelay = await Promise.all( + relays.map(async (url) => { + try { + const result = await nostrClient.pool.list([url], [filter]); + return Array.isArray(result) ? result : []; + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Relay fetch failed for ${identifier} on ${url}:`, + error + ); + } + return []; + } + }) + ); + events = perRelay.flat(); + } + + if (!events.length) { + return []; + } + + const newest = events.reduce((latest, event) => { + if (!latest) { + return event; + } + if (event.created_at === latest.created_at) { + return event.id > latest.id ? event : latest; + } + return event.created_at > latest.created_at ? event : latest; + }, null); + + return extractNpubsFromEvent(newest); +} + +async function loadNostrState() { + const [editors, whitelist, blacklist] = await Promise.all([ + loadNostrList(LIST_IDENTIFIERS.editors), + loadNostrList(LIST_IDENTIFIERS.whitelist), + loadNostrList(LIST_IDENTIFIERS.blacklist), + ]); + + return sanitizeAdminState({ editors, whitelist, blacklist }); +} + +function buildListEvent(listKey, npubs, actorHex) { + const identifier = LIST_IDENTIFIERS[listKey]; + const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`; + + const hexPubkeys = Array.from( + new Set(npubs.map((npub) => { + try { + return decodeNpubToHex(npub); + } catch (error) { + if (isDevMode) { + console.warn( + `[adminListStore] Failed to decode npub while publishing ${listKey}:`, + error + ); + } + return ""; + } + }).filter((hex) => !!hex)) + ); + + if (listKey === "whitelist" || listKey === "editors") { + try { + const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB); + if (superHex && !hexPubkeys.includes(superHex)) { + hexPubkeys.push(superHex); + } + } catch (error) { + if (isDevMode) { + console.warn("Failed to ensure super admin presence:", error); + } + } + } + + const tags = [["d", dTagValue]]; + hexPubkeys.forEach((hex) => { + tags.push(["p", hex]); + }); + + return { + kind: 30000, + pubkey: actorHex, + created_at: Math.floor(Date.now() / 1000), + tags, + content: "", + }; +} + +function publishToRelay(url, signedEvent, listKey) { + return new Promise((resolve) => { + let settled = false; + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + resolve({ url, success: false, error: new Error("timeout") }); + } + }, PUBLISH_TIMEOUT_MS); + + const finalize = (success, error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + resolve({ url, success, error }); + }; + + try { + const pub = nostrClient.pool.publish([url], signedEvent); + if (!pub) { + finalize(false, new Error("publish returned no result")); + return; + } + + if (typeof pub.on === "function") { + pub.on("ok", () => finalize(true)); + pub.on("seen", () => finalize(true)); + pub.on("failed", (reason) => { + const error = + reason instanceof Error ? reason : new Error(String(reason || "failed")); + finalize(false, error); + }); + return; + } + + if (typeof pub.then === "function") { + pub.then(() => finalize(true)).catch((error) => finalize(false, error)); + return; + } + } catch (error) { + finalize(false, error); + return; + } + + finalize(true); + }).then((result) => { + if (!result.success && isDevMode) { + console.warn( + `[adminListStore] Publish failed for ${listKey} on ${result.url}:`, + result.error + ); + } + return result; + }); +} + +async function persistNostrState(actorNpub, updates = {}) { + ensureNostrReady(); + const extension = window?.nostr; + if (!extension || typeof extension.signEvent !== "function") { + throw createError( + "nostr-extension-missing", + "A Nostr extension with signEvent support is required." + ); + } + + const actorHex = decodeNpubToHex(actorNpub); + if (!actorHex) { + throw createError("invalid npub", "Unable to decode the actor npub."); + } + + const sanitizedUpdates = {}; + + if (Array.isArray(updates.editors)) { + sanitizedUpdates.editors = sanitizeNpubList(updates.editors).filter((npub) => { + const normalized = normalizeNpub(npub); + return normalized && normalized !== normalizeNpub(ADMIN_SUPER_NPUB); + }); + } + + if (Array.isArray(updates.whitelist)) { + sanitizedUpdates.whitelist = sanitizeNpubList(updates.whitelist); + } + + if (Array.isArray(updates.blacklist)) { + const whitelistSet = new Set( + (sanitizedUpdates.whitelist || []).map(normalizeNpub) + ); + const editorGuard = new Set([ + normalizeNpub(ADMIN_SUPER_NPUB), + ...ADMIN_EDITORS_NPUBS.map(normalizeNpub), + ...((sanitizedUpdates.editors || []).map(normalizeNpub) || []), + ]); + + sanitizedUpdates.blacklist = sanitizeNpubList(updates.blacklist).filter( + (npub) => { + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + if (whitelistSet.has(normalized)) { + return false; + } + if (editorGuard.has(normalized)) { + return false; + } + return true; + } + ); + } + + const entries = Object.entries(sanitizedUpdates).filter(([, value]) => + Array.isArray(value) + ); + + if (!entries.length) { + return; + } + + for (const [listKey, npubs] of entries) { + const event = buildListEvent(listKey, npubs, actorHex); + + let signedEvent; + try { + signedEvent = await extension.signEvent(event); + } catch (error) { + throw createError("signature-failed", "Failed to sign admin list event.", error); + } + + const relays = ensureNostrReady(); + const publishResults = await Promise.all( + relays.map((url) => publishToRelay(url, signedEvent, listKey)) + ); + + if (!publishResults.some((result) => result.success)) { + throw createError( + "publish-failed", + `Failed to publish ${listKey} list to any relay.` + ); + } + + clearLegacyStorageFor(listKey); + } +} + +export async function loadAdminState() { + if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { + console.warn( + `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Defaulting to remote Nostr lists.` + ); + } + + const state = await loadNostrState(); + if (!hasAnyEntries(state)) { + const legacy = loadLegacyAdminState(); + if (legacy && isDevMode) { + console.warn( + "[adminListStore] Ignoring legacy admin lists because remote mode is enforced. Publish them to Nostr to retain access.", + legacy + ); + } + } + + return state; +} + +export async function persistAdminState(actorNpub, updates) { + if (ADMIN_LIST_MODE !== "nostr" && isDevMode) { + console.warn( + `[adminListStore] ADMIN_LIST_MODE "${ADMIN_LIST_MODE}" is deprecated. Remote lists are enforced.` + ); + } + + return persistNostrState(actorNpub, updates); +} diff --git a/js/app.js b/js/app.js index b12d9236..e82acd1c 100644 --- a/js/app.js +++ b/js/app.js @@ -523,8 +523,6 @@ class bitvidApp { this.adminWhitelistList = null; this.adminWhitelistEmpty = null; this.adminWhitelistSection = null; - this.adminWhitelistModeToggle = null; - this.adminWhitelistModeWrapper = null; this.adminBlacklistInput = null; this.adminAddBlacklistBtn = null; this.adminBlacklistList = null; @@ -900,6 +898,12 @@ class bitvidApp { // 4. Connect to Nostr await nostrClient.init(); + try { + await accessControl.refresh(); + } catch (error) { + console.warn("Failed to refresh admin lists after connecting to Nostr:", error); + } + // Grab the "Subscriptions" link by its id in the sidebar this.subscriptionsLink = document.getElementById("subscriptionsLink"); @@ -3316,10 +3320,6 @@ class bitvidApp { document.getElementById("adminWhitelistInput") || null; this.adminAddWhitelistBtn = document.getElementById("adminAddWhitelistBtn") || null; - this.adminWhitelistModeToggle = - document.getElementById("adminWhitelistModeToggle") || null; - this.adminWhitelistModeWrapper = - document.getElementById("adminWhitelistModeWrapper") || null; this.adminBlacklistSection = document.getElementById("adminBlacklistSection") || null; this.adminBlacklistEmpty = @@ -3451,23 +3451,10 @@ class bitvidApp { }); } - if ( - this.adminWhitelistModeToggle && - this.adminWhitelistModeToggle.dataset.bound !== "true" - ) { - this.adminWhitelistModeToggle.dataset.bound = "true"; - this.adminWhitelistModeToggle.addEventListener("change", (event) => { - const enabled = event.target instanceof HTMLInputElement - ? event.target.checked - : false; - this.handleWhitelistModeToggle(enabled); - }); - } - this.selectProfilePane("account"); this.populateProfileRelays(); this.populateBlockedList(); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); console.log("Profile modal initialization successful"); return true; @@ -3551,12 +3538,16 @@ class bitvidApp { }); } - openProfileModal() { + async openProfileModal() { if (!this.profileModal) { return; } - this.refreshAdminPaneState(); + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane while opening profile modal:", error); + } this.selectProfilePane("account"); this.populateProfileRelays(); @@ -3821,11 +3812,18 @@ class bitvidApp { }); } - refreshAdminPaneState() { - accessControl.refresh(); + async refreshAdminPaneState() { const adminNav = this.profileNavButtons.admin; const adminPane = this.profilePaneElements.admin; + let loadError = null; + this.setAdminLoading(true); + try { + await accessControl.ensureReady(); + } catch (error) { + loadError = error; + } + const actorNpub = this.getCurrentUserNpub(); const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub); const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub); @@ -3842,6 +3840,18 @@ class bitvidApp { adminPane.setAttribute("aria-hidden", (!canEdit).toString()); } + if (loadError) { + console.error("Failed to load admin lists:", loadError); + const message = + loadError?.code === "nostr-unavailable" + ? "Unable to reach Nostr relays. Moderation lists may be out of date." + : "Unable to load moderation lists. Please try again."; + this.showError(message); + this.clearAdminLists(); + this.setAdminLoading(false); + return; + } + if (!canEdit) { this.clearAdminLists(); if (typeof actorNpub !== "string" || !actorNpub) { @@ -3850,6 +3860,7 @@ class bitvidApp { if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) { this.selectProfilePane("account"); } + this.setAdminLoading(false); return; } @@ -3857,17 +3868,50 @@ class bitvidApp { this.adminModeratorsSection.classList.toggle("hidden", !isSuperAdmin); this.adminModeratorsSection.setAttribute("aria-hidden", (!isSuperAdmin).toString()); } - if (this.adminWhitelistModeWrapper instanceof HTMLElement) { - this.adminWhitelistModeWrapper.classList.toggle("hidden", !isSuperAdmin); - } - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); + this.populateAdminLists(); + this.setAdminLoading(false); + } + + storeAdminEmptyMessages() { + const capture = (element) => { + if (element instanceof HTMLElement && !element.dataset.defaultMessage) { + element.dataset.defaultMessage = element.textContent || ""; + } + }; + + capture(this.adminModeratorsEmpty); + capture(this.adminWhitelistEmpty); + capture(this.adminBlacklistEmpty); + } + + setAdminLoading(isLoading) { + this.storeAdminEmptyMessages(); + if (this.profilePaneElements.admin instanceof HTMLElement) { + this.profilePaneElements.admin.setAttribute( + "aria-busy", + isLoading ? "true" : "false" + ); } - this.populateAdminLists(); + const toggleMessage = (element, message) => { + if (!(element instanceof HTMLElement)) { + return; + } + if (isLoading) { + element.textContent = message; + element.classList.remove("hidden"); + } else { + element.textContent = element.dataset.defaultMessage || element.textContent; + } + }; + + toggleMessage(this.adminModeratorsEmpty, "Loading moderators…"); + toggleMessage(this.adminWhitelistEmpty, "Loading whitelist…"); + toggleMessage(this.adminBlacklistEmpty, "Loading blacklist…"); } clearAdminLists() { + this.storeAdminEmptyMessages(); if (this.adminModeratorList) { this.adminModeratorList.innerHTML = ""; } @@ -3878,17 +3922,23 @@ class bitvidApp { this.adminBlacklistList.innerHTML = ""; } if (this.adminModeratorsEmpty instanceof HTMLElement) { + this.adminModeratorsEmpty.textContent = + this.adminModeratorsEmpty.dataset.defaultMessage || + this.adminModeratorsEmpty.textContent; this.adminModeratorsEmpty.classList.remove("hidden"); } if (this.adminWhitelistEmpty instanceof HTMLElement) { + this.adminWhitelistEmpty.textContent = + this.adminWhitelistEmpty.dataset.defaultMessage || + this.adminWhitelistEmpty.textContent; this.adminWhitelistEmpty.classList.remove("hidden"); } if (this.adminBlacklistEmpty instanceof HTMLElement) { + this.adminBlacklistEmpty.textContent = + this.adminBlacklistEmpty.dataset.defaultMessage || + this.adminBlacklistEmpty.textContent; this.adminBlacklistEmpty.classList.remove("hidden"); } - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } } renderAdminList(listEl, emptyEl, entries, options = {}) { @@ -4018,7 +4068,20 @@ class bitvidApp { return actorNpub; } - handleAddModerator() { + async handleAddModerator() { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before adding moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub || !this.adminModeratorInput) { return; @@ -4036,7 +4099,7 @@ class bitvidApp { } try { - const result = accessControl.addModerator(actorNpub, value); + const result = await accessControl.addModerator(actorNpub, value); if (!result.ok) { this.showError(this.describeAdminError(result.error)); return; @@ -4044,7 +4107,7 @@ class bitvidApp { this.adminModeratorInput.value = ""; this.showSuccess("Moderator added successfully."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } finally { if (this.adminAddModeratorBtn) { this.adminAddModeratorBtn.disabled = false; @@ -4053,7 +4116,24 @@ class bitvidApp { } } - handleRemoveModerator(npub, button) { + async handleRemoveModerator(npub, button) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before removing moderator:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (button instanceof HTMLElement) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(true); if (!actorNpub) { if (button instanceof HTMLElement) { @@ -4063,7 +4143,7 @@ class bitvidApp { return; } - const result = accessControl.removeModerator(actorNpub, npub); + const result = await accessControl.removeModerator(actorNpub, npub); if (!result.ok) { this.showError(this.describeAdminError(result.error)); if (button instanceof HTMLElement) { @@ -4074,10 +4154,27 @@ class bitvidApp { } this.showSuccess("Moderator removed."); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); } - handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + let preloadError = null; + try { + await accessControl.ensureReady(); + } catch (error) { + preloadError = error; + console.error("Failed to load admin lists before updating entries:", error); + } + + if (preloadError) { + this.showError(this.describeAdminError(preloadError.code || "storage-error")); + if (sourceButton instanceof HTMLElement) { + sourceButton.disabled = false; + sourceButton.removeAttribute("aria-busy"); + } + return; + } + const actorNpub = this.ensureAdminActor(false); if (!actorNpub) { if (sourceButton instanceof HTMLElement) { @@ -4115,12 +4212,12 @@ class bitvidApp { let result; if (isWhitelist) { result = isAdd - ? accessControl.addToWhitelist(actorNpub, target) - : accessControl.removeFromWhitelist(actorNpub, target); + ? await accessControl.addToWhitelist(actorNpub, target) + : await accessControl.removeFromWhitelist(actorNpub, target); } else { result = isAdd - ? accessControl.addToBlacklist(actorNpub, target) - : accessControl.removeFromBlacklist(actorNpub, target); + ? await accessControl.addToBlacklist(actorNpub, target) + : await accessControl.removeFromBlacklist(actorNpub, target); } if (!result.ok) { @@ -4144,7 +4241,7 @@ class bitvidApp { ? "Added to the blacklist." : "Removed from the blacklist."; this.showSuccess(successMessage); - this.onAccessControlUpdated(); + await this.onAccessControlUpdated(); if (buttonToToggle instanceof HTMLElement) { buttonToToggle.disabled = false; @@ -4160,39 +4257,28 @@ class bitvidApp { return "That account cannot be modified."; case "forbidden": return "You do not have permission to perform that action."; + case "nostr-unavailable": + return "Unable to reach the configured Nostr relays. Please retry once your connection is restored."; + case "nostr-extension-missing": + return "Connect a Nostr extension before editing moderation lists."; + case "signature-failed": + return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again."; + case "publish-failed": + return "Failed to publish the update to Nostr relays. Please try again."; + case "storage-error": + return "Unable to update moderation settings. Please try again."; default: return "Unable to update moderation settings. Please try again."; } } - handleWhitelistModeToggle(enabled) { - const actorNpub = this.ensureAdminActor(true); - if (!actorNpub) { - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - return; + async onAccessControlUpdated() { + try { + await this.refreshAdminPaneState(); + } catch (error) { + console.error("Failed to refresh admin pane after update:", error); } - const result = accessControl.setWhitelistMode(actorNpub, enabled); - if (!result.ok) { - this.showError(this.describeAdminError(result.error)); - if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { - this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); - } - return; - } - - this.showSuccess( - enabled - ? "Whitelist mode enabled. Only approved creators will appear in feeds." - : "Whitelist mode disabled. All creators except those blacklisted will appear." - ); - this.onAccessControlUpdated(); - } - - onAccessControlUpdated() { - this.refreshAdminPaneState(); this.loadVideos(true).catch((error) => { console.error("Failed to refresh videos after admin update:", error); }); @@ -4213,7 +4299,9 @@ class bitvidApp { // 2) Profile button if (this.profileButton) { this.profileButton.addEventListener("click", () => { - this.openProfileModal(); + this.openProfileModal().catch((error) => { + console.error("Failed to open profile modal:", error); + }); }); } @@ -4823,7 +4911,7 @@ class bitvidApp { return; } - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Hide login button if present if (this.loginButton) { @@ -4907,7 +4995,7 @@ class bitvidApp { // Clear localStorage localStorage.removeItem("userPubKey"); - this.refreshAdminPaneState(); + await this.refreshAdminPaneState(); // Refresh the video list so user sees only public videos again await this.loadVideos(); @@ -5346,7 +5434,11 @@ class bitvidApp { async loadVideos(forceFetch = false) { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before fetching videos:", error); + } const shouldIncludeVideo = (video) => { if (!video || typeof video !== "object") { @@ -7649,7 +7741,11 @@ class bitvidApp { return; } - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before playback:", error); + } const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (!accessControl.canAccess(authorNpub)) { if (accessControl.isBlacklisted(authorNpub)) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 51719d31..e448f3dd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -251,7 +251,11 @@ async function loadUserProfile(pubkey) { */ async function loadUserVideos(pubkey) { try { - accessControl.refresh(); + try { + await accessControl.ensureReady(); + } catch (error) { + console.warn("Failed to ensure admin lists were loaded before channel fetch:", error); + } // 1) Build filter for videos from this pubkey const filter = { diff --git a/js/config.js b/js/config.js index f35124bc..bffab224 100644 --- a/js/config.js +++ b/js/config.js @@ -3,10 +3,10 @@ export const isDevMode = true; // Set to false for production // ----------------------------------------------------------------------------- -// Admin governance (v1 local mode) +// Admin governance — production defaults rely on remote Nostr lists // ----------------------------------------------------------------------------- -export const ADMIN_LIST_MODE = "local"; // Future: "nostr" +export const ADMIN_LIST_MODE = "nostr"; export const ADMIN_SUPER_NPUB = "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional) diff --git a/js/lists.js b/js/lists.js index 8f77332b..aaa15320 100644 --- a/js/lists.js +++ b/js/lists.js @@ -1,26 +1,10 @@ // js/lists.js -// Whitelist of npubs that can access the video upload functions -const npubs = [ - "npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe", // bitvid - "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx", // thePR0M3TH3AN - "npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8", // Ghost Grid Network - "npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2", // Chain Reaction - "npub1qny3tkh0acurzla8x3zy4nhrjz5zd8l9sy9jys09umwng00manysew95gx", // ODELL - "npub1qxduthz4p8v5zsux524df569pt7lj0d36dyqadnta2val64dtnhsr50h64", // NosToons - "npub19ma2w9dmk3kat0nt0k5dwuqzvmg3va9ezwup0zkakhpwv0vcwvcsg8axkl", // vinney - "npub1rcr8h76csgzhdhea4a7tq5w5gydcpg9clgf0cffu6z45rnc6yp5sj7cfuz", // djmeistro - "npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster - "npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal - "npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox - "npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex -]; - -console.log("DEBUG: lists.js loaded, npubs:", npubs); - -// Blacklist of npubs that events will not be displayed in the bitvid official client -export const ADMIN_INITIAL_WHITELIST = npubs; -export const ADMIN_INITIAL_BLACKLIST = [""]; +// Operators must manage admin lists through the Admin panel UI or remote +// adapters. These legacy constants are intentionally empty so new installs do +// not ship with hard-coded accounts. +export const ADMIN_INITIAL_WHITELIST = []; +export const ADMIN_INITIAL_BLACKLIST = []; // Block specific events with the nevent -export const ADMIN_INITIAL_EVENT_BLACKLIST = [""]; +export const ADMIN_INITIAL_EVENT_BLACKLIST = [];