From 875cee44288f7d4b7253b22e173cba6b2a9bc59d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:38:10 -0400 Subject: [PATCH] Add admin governance roles and UI --- components/profile-modal.html | 145 ++++++++ js/accessControl.js | 486 ++++++++++++++++----------- js/app.js | 599 ++++++++++++++++++++++++++++++++-- js/channelProfile.js | 40 ++- js/config.js | 37 ++- js/lists.js | 6 +- 6 files changed, 1086 insertions(+), 227 deletions(-) diff --git a/components/profile-modal.html b/components/profile-modal.html index 5a3f0f85..dc4c80e9 100644 --- a/components/profile-modal.html +++ b/components/profile-modal.html @@ -88,6 +88,15 @@ > Blocked + @@ -176,6 +185,142 @@ + diff --git a/js/accessControl.js b/js/accessControl.js index 4cc051ba..6e7c40ee 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -1,228 +1,334 @@ // js/accessControl.js -import { isDevMode, isWhitelistEnabled } from "./config.js"; -import { initialWhitelist, initialBlacklist } from "./lists.js"; +import { + ADMIN_EDITORS_NPUBS, + ADMIN_SUPER_NPUB, + getWhitelistMode, + setWhitelistMode as persistWhitelistMode, + ADMIN_WHITELIST_MODE_STORAGE_KEY, +} from "./config.js"; +import { + ADMIN_INITIAL_BLACKLIST, + ADMIN_INITIAL_WHITELIST, +} from "./lists.js"; -class AccessControl { - constructor() { - // Debug logging for initialization - console.log("DEBUG: AccessControl constructor called"); +const ADMIN_EDITORS_KEY = "bitvid_admin_editors"; +const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist"; +const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist"; - const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist(); - const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist(); +const LEGACY_WHITELIST_KEY = "bitvid_whitelist"; +const LEGACY_BLACKLIST_KEY = "bitvid_blacklist"; - if (storedWhitelist !== null) { - this.whitelist = new Set(storedWhitelist); - } else { - this.whitelist = new Set(initialWhitelist); - this.saveWhitelist(); - if (whitelistStatus && whitelistStatus !== "missing") { - console.warn( - `Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.` - ); - } - } - - if (storedBlacklist !== null) { - this.blacklist = new Set(storedBlacklist); - } else { - this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings - this.saveBlacklist(); - if (blacklistStatus && blacklistStatus !== "missing") { - console.warn( - `Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.` - ); - } - } - - // Debug the sets - console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]); - console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]); - } - - // Rest of the class remains the same... - loadWhitelist() { - try { - const stored = localStorage.getItem("bitvid_whitelist"); - if (!stored) { - if (isDevMode) console.log("No stored whitelist found in localStorage."); - return { data: null, status: "missing" }; - } - - const parsed = JSON.parse(stored); - const sanitized = this.sanitizeList(parsed, "whitelist"); - return { - data: sanitized, - status: sanitized === null ? "invalid" : "ok", - }; - } catch (error) { - console.error("Error loading whitelist, using defaults:", error); - return { data: null, status: "error" }; - } - } - - loadBlacklist() { - try { - const stored = localStorage.getItem("bitvid_blacklist"); - if (!stored) { - if (isDevMode) console.log("No stored blacklist found in localStorage."); - return { data: null, status: "missing" }; - } - - const parsed = JSON.parse(stored); - const sanitized = this.sanitizeList(parsed, "blacklist"); - return { - data: sanitized, - status: sanitized === null ? "invalid" : "ok", - }; - } catch (error) { - console.error("Error loading blacklist, using defaults:", error); - return { data: null, status: "error" }; - } - } - - sanitizeList(list, listName) { - if (!Array.isArray(list)) { - console.warn( - `Stored ${listName} is not an array. Received:`, - list - ); +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; + } +} - const sanitized = []; - let invalidEntries = 0; +function saveJSONList(key, values) { + try { + localStorage.setItem(key, JSON.stringify(values)); + } catch (error) { + console.warn(`Failed to persist list for ${key}:`, error); + } +} - list.forEach((value) => { - if (typeof value !== "string") { - invalidEntries += 1; - return; +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()) + ); +} - const trimmed = value.trim(); - if (trimmed) { - sanitized.push(trimmed); - } else { - invalidEntries += 1; - } - }); - - if (invalidEntries > 0) { - console.warn( - `Stored ${listName} contained ${invalidEntries} invalid entr${ - invalidEntries === 1 ? "y" : "ies" - }. Sanitized list:`, - sanitized - ); - } +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; } - saveWhitelist() { - try { - localStorage.setItem( - "bitvid_whitelist", - JSON.stringify([...this.whitelist]) - ); - } catch (error) { - console.error("Error saving whitelist:", error); - } + const sanitizedFallback = dedupeNpubs(fallback); + if (sanitizedFallback.length) { + saveJSONList(targetKey, sanitizedFallback); + } + return sanitizedFallback; +} + +function isValidNpub(npub) { + if (typeof npub !== "string") { + return false; + } + const trimmed = npub.trim(); + if (!trimmed) { + return false; } - saveBlacklist() { - try { - localStorage.setItem( - "bitvid_blacklist", - JSON.stringify([...this.blacklist]) - ); - } catch (error) { - console.error("Error saving blacklist:", error); - } + try { + const decoded = window?.NostrTools?.nip19?.decode(trimmed); + return decoded?.type === "npub"; + } catch (error) { + return false; + } +} + +class AccessControl { + constructor() { + this.editors = new Set(); + this.whitelist = new Set(); + this.blacklist = new Set(); + this.whitelistEnabled = getWhitelistMode(); + this.refresh(); } - addToWhitelist(npub) { - if (!this.isValidNpub(npub)) { - throw new Error("Invalid npub format"); - } - this.whitelist.add(npub); - this.saveWhitelist(); - if (isDevMode) console.log(`Added ${npub} to whitelist`); + refresh() { + this.editors = new Set( + dedupeNpubs([ + ...ADMIN_EDITORS_NPUBS, + ...migrateLegacyList(ADMIN_EDITORS_KEY, null, []), + ]) + ); + + 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(); } - removeFromWhitelist(npub) { - this.whitelist.delete(npub); - this.saveWhitelist(); - if (isDevMode) console.log(`Removed ${npub} from whitelist`); + whitelistMode() { + return this.whitelistEnabled; } - addToBlacklist(npub) { - if (!this.isValidNpub(npub)) { - throw new Error("Invalid npub format"); - } - this.blacklist.add(npub); - this.saveBlacklist(); - if (isDevMode) console.log(`Added ${npub} to blacklist`); + isSuperAdmin(npub) { + const normalized = normalizeNpub(npub); + return normalized ? normalized === ADMIN_SUPER_NPUB : false; } - removeFromBlacklist(npub) { - this.blacklist.delete(npub); - this.saveBlacklist(); - if (isDevMode) console.log(`Removed ${npub} from blacklist`); - } - - isWhitelisted(npub) { - const result = this.whitelist.has(npub); - if (isDevMode) - console.log( - `Checking if ${npub} is whitelisted:`, - result, - "Current whitelist:", - [...this.whitelist] - ); - return result; - } - - isBlacklisted(npub) { - return this.blacklist.has(npub); - } - - canAccess(npub) { - if (this.isBlacklisted(npub)) { + isAdminEditor(npub) { + const normalized = normalizeNpub(npub); + if (!normalized) { return false; } - const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub); - if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess); - return canAccess; - } - - filterVideos(videos) { - return videos.filter((video) => { - try { - const npub = window.NostrTools.nip19.npubEncode(video.pubkey); - return !this.isBlacklisted(npub); - } catch (error) { - console.error("Error filtering video:", error); - return false; - } - }); - } - - isValidNpub(npub) { - try { - return npub.startsWith("npub1") && npub.length === 63; - } catch (error) { - return false; + if (this.isSuperAdmin(normalized)) { + return true; } + return this.editors.has(normalized); + } + + canEditAdminLists(npub) { + return this.isAdminEditor(npub); } getWhitelist() { - return [...this.whitelist]; + return Array.from(this.whitelist); } getBlacklist() { - return [...this.blacklist]; + return Array.from(this.blacklist); + } + + getEditors() { + return Array.from(this.editors); + } + + addModerator(requestorNpub, moderatorNpub) { + if (!this.isSuperAdmin(requestorNpub)) { + return { ok: false, error: "forbidden" }; + } + if (!isValidNpub(moderatorNpub)) { + return { ok: false, error: "invalid npub" }; + } + + const normalized = normalizeNpub(moderatorNpub); + if (!normalized || normalized === ADMIN_SUPER_NPUB) { + return { ok: false, error: "immutable" }; + } + + this.editors.add(normalized); + saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); + this.refresh(); + return { ok: true }; + } + + removeModerator(requestorNpub, moderatorNpub) { + if (!this.isSuperAdmin(requestorNpub)) { + return { ok: false, error: "forbidden" }; + } + + const normalized = normalizeNpub(moderatorNpub); + if (!normalized || normalized === ADMIN_SUPER_NPUB) { + return { ok: false, error: "immutable" }; + } + + this.editors.delete(normalized); + saveJSONList(ADMIN_EDITORS_KEY, this.getEditors()); + this.refresh(); + return { ok: true }; + } + + addToWhitelist(actorNpub, targetNpub) { + if (!this.canEditAdminLists(actorNpub)) { + return { ok: false, error: "forbidden" }; + } + if (!isValidNpub(targetNpub)) { + return { ok: false, error: "invalid npub" }; + } + + const normalized = normalizeNpub(targetNpub); + if (!normalized) { + 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 }; + } + + removeFromWhitelist(actorNpub, targetNpub) { + if (!this.canEditAdminLists(actorNpub)) { + return { ok: false, error: "forbidden" }; + } + + const normalized = normalizeNpub(targetNpub); + if (!normalized) { + return { ok: false, error: "invalid npub" }; + } + + this.whitelist.delete(normalized); + saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist()); + this.refresh(); + return { ok: true }; + } + + addToBlacklist(actorNpub, targetNpub) { + if (!this.canEditAdminLists(actorNpub)) { + return { ok: false, error: "forbidden" }; + } + if (!isValidNpub(targetNpub)) { + return { ok: false, error: "invalid npub" }; + } + + const normalized = normalizeNpub(targetNpub); + if (!normalized) { + return { ok: false, error: "invalid npub" }; + } + + if (this.isSuperAdmin(normalized) || this.isAdminEditor(normalized)) { + 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 }; + } + + removeFromBlacklist(actorNpub, targetNpub) { + if (!this.canEditAdminLists(actorNpub)) { + return { ok: false, error: "forbidden" }; + } + + const normalized = normalizeNpub(targetNpub); + if (!normalized) { + return { ok: false, error: "invalid npub" }; + } + + this.blacklist.delete(normalized); + saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist()); + this.refresh(); + return { ok: true }; + } + + setWhitelistMode(actorNpub, enabled) { + if (!this.isSuperAdmin(actorNpub)) { + return { ok: false, error: "forbidden" }; + } + persistWhitelistMode(!!enabled); + this.whitelistEnabled = !!enabled; + return { ok: true }; + } + + isBlacklisted(npub) { + const normalized = normalizeNpub(npub); + return normalized ? this.blacklist.has(normalized) : false; + } + + canAccess(candidate) { + let npub = ""; + + if (typeof candidate === "string") { + npub = candidate; + } else if (candidate && typeof candidate === "object") { + if (typeof candidate.npub === "string") { + npub = candidate.npub; + } else if (typeof candidate.pubkey === "string") { + try { + npub = window.NostrTools.nip19.npubEncode(candidate.pubkey); + } catch (error) { + npub = candidate.pubkey; + } + } + } + + const normalized = normalizeNpub(npub); + if (!normalized) { + return false; + } + + if (this.blacklist.has(normalized)) { + return false; + } + + if (this.whitelistEnabled && !this.whitelist.has(normalized)) { + return false; + } + + return true; } } 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/app.js b/js/app.js index 7509f987..b12d9236 100644 --- a/js/app.js +++ b/js/app.js @@ -3,8 +3,8 @@ import { loadView } from "./viewManager.js"; import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; -import { isDevMode } from "./config.js"; -import { isWhitelistEnabled } from "./config.js"; +import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; +import { accessControl } from "./accessControl.js"; import { safeDecodeMagnet } from "./magnetUtils.js"; import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; @@ -12,11 +12,7 @@ import { URL_FIRST_ENABLED } from "./constants.js"; import { trackVideoView } from "./analytics.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; -import { - initialWhitelist, - initialBlacklist, - initialEventBlacklist, -} from "./lists.js"; +import { ADMIN_INITIAL_EVENT_BLACKLIST } from "./lists.js"; import { loadR2Settings, saveR2Settings, @@ -503,11 +499,13 @@ class bitvidApp { account: null, relays: null, blocked: null, + admin: null, }; this.profilePaneElements = { account: null, relays: null, blocked: null, + admin: null, }; this.profileRelayList = null; this.profileBlockedList = null; @@ -515,10 +513,28 @@ class bitvidApp { this.profileRelayInput = null; this.profileAddRelayBtn = null; this.profileRestoreRelaysBtn = null; + this.adminModeratorInput = null; + this.adminAddModeratorBtn = null; + this.adminModeratorList = null; + this.adminModeratorsEmpty = null; + this.adminModeratorsSection = null; + this.adminWhitelistInput = null; + this.adminAddWhitelistBtn = null; + this.adminWhitelistList = null; + this.adminWhitelistEmpty = null; + this.adminWhitelistSection = null; + this.adminWhitelistModeToggle = null; + this.adminWhitelistModeWrapper = null; + this.adminBlacklistInput = null; + this.adminAddBlacklistBtn = null; + this.adminBlacklistList = null; + this.adminBlacklistEmpty = null; + this.adminBlacklistSection = null; this.lastFocusedBeforeProfileModal = null; this.boundProfileModalKeydown = null; this.boundProfileModalFocusIn = null; this.profileModalFocusables = []; + this.currentUserNpub = null; // Upload modal elements this.uploadButton = document.getElementById("uploadButton") || null; @@ -655,7 +671,7 @@ class bitvidApp { // Build a set of blacklisted event IDs (hex) from nevent strings, skipping empties this.blacklistedEventIds = new Set(); - for (const neventStr of initialEventBlacklist) { + for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) { // Skip any empty or obviously invalid strings if (!neventStr || neventStr.trim().length < 8) { continue; @@ -3262,12 +3278,16 @@ class bitvidApp { document.getElementById("profileNavRelays") || null; this.profileNavButtons.blocked = document.getElementById("profileNavBlocked") || null; + this.profileNavButtons.admin = + document.getElementById("profileNavAdmin") || null; this.profilePaneElements.account = document.getElementById("profilePaneAccount") || null; this.profilePaneElements.relays = document.getElementById("profilePaneRelays") || null; this.profilePaneElements.blocked = document.getElementById("profilePaneBlocked") || null; + this.profilePaneElements.admin = + document.getElementById("profilePaneAdmin") || null; this.profileRelayList = document.getElementById("relayList") || null; this.profileBlockedList = document.getElementById("blockedList") || null; this.profileBlockedEmpty = @@ -3276,6 +3296,40 @@ class bitvidApp { this.profileAddRelayBtn = document.getElementById("addRelayBtn") || null; this.profileRestoreRelaysBtn = document.getElementById("restoreRelaysBtn") || null; + this.adminModeratorsSection = + document.getElementById("adminModeratorsSection") || null; + this.adminModeratorsEmpty = + document.getElementById("adminModeratorsEmpty") || null; + this.adminModeratorList = + document.getElementById("adminModeratorList") || null; + this.adminModeratorInput = + document.getElementById("adminModeratorInput") || null; + this.adminAddModeratorBtn = + document.getElementById("adminAddModeratorBtn") || null; + this.adminWhitelistSection = + document.getElementById("adminWhitelistSection") || null; + this.adminWhitelistEmpty = + document.getElementById("adminWhitelistEmpty") || null; + this.adminWhitelistList = + document.getElementById("adminWhitelistList") || null; + this.adminWhitelistInput = + 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 = + document.getElementById("adminBlacklistEmpty") || null; + this.adminBlacklistList = + document.getElementById("adminBlacklistList") || null; + this.adminBlacklistInput = + document.getElementById("adminBlacklistInput") || null; + this.adminAddBlacklistBtn = + document.getElementById("adminAddBlacklistBtn") || null; // Wire up if (this.closeProfileModal && !this.closeProfileModal.dataset.bound) { @@ -3331,9 +3385,89 @@ class bitvidApp { }); } + if ( + this.adminAddModeratorBtn && + this.adminAddModeratorBtn.dataset.bound !== "true" + ) { + this.adminAddModeratorBtn.dataset.bound = "true"; + this.adminAddModeratorBtn.addEventListener("click", () => { + this.handleAddModerator(); + }); + } + if ( + this.adminModeratorInput && + this.adminModeratorInput.dataset.bound !== "true" + ) { + this.adminModeratorInput.dataset.bound = "true"; + this.adminModeratorInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.handleAddModerator(); + } + }); + } + + if ( + this.adminAddWhitelistBtn && + this.adminAddWhitelistBtn.dataset.bound !== "true" + ) { + this.adminAddWhitelistBtn.dataset.bound = "true"; + this.adminAddWhitelistBtn.addEventListener("click", () => { + this.handleAdminListMutation("whitelist", "add"); + }); + } + if ( + this.adminWhitelistInput && + this.adminWhitelistInput.dataset.bound !== "true" + ) { + this.adminWhitelistInput.dataset.bound = "true"; + this.adminWhitelistInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.handleAdminListMutation("whitelist", "add"); + } + }); + } + + if ( + this.adminAddBlacklistBtn && + this.adminAddBlacklistBtn.dataset.bound !== "true" + ) { + this.adminAddBlacklistBtn.dataset.bound = "true"; + this.adminAddBlacklistBtn.addEventListener("click", () => { + this.handleAdminListMutation("blacklist", "add"); + }); + } + if ( + this.adminBlacklistInput && + this.adminBlacklistInput.dataset.bound !== "true" + ) { + this.adminBlacklistInput.dataset.bound = "true"; + this.adminBlacklistInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.handleAdminListMutation("blacklist", "add"); + } + }); + } + + 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(); console.log("Profile modal initialization successful"); return true; @@ -3346,12 +3480,27 @@ class bitvidApp { selectProfilePane(name = "account") { const normalized = typeof name === "string" ? name.toLowerCase() : "account"; - const target = ["account", "relays", "blocked"].includes(normalized) + const availableKeys = Object.keys(this.profilePaneElements).filter((key) => { + const pane = this.profilePaneElements[key]; + if (!(pane instanceof HTMLElement)) { + return false; + } + const button = this.profileNavButtons[key]; + if (button instanceof HTMLElement && button.classList.contains("hidden")) { + return false; + } + return true; + }); + + const fallbackTarget = availableKeys.includes("account") + ? "account" + : availableKeys[0] || "account"; + const target = availableKeys.includes(normalized) ? normalized - : "account"; + : fallbackTarget; Object.entries(this.profilePaneElements).forEach(([key, pane]) => { - if (!pane) { + if (!(pane instanceof HTMLElement)) { return; } const isActive = key === target; @@ -3360,7 +3509,7 @@ class bitvidApp { }); Object.entries(this.profileNavButtons).forEach(([key, button]) => { - if (!button) { + if (!(button instanceof HTMLElement)) { return; } const isActive = key === target; @@ -3407,6 +3556,8 @@ class bitvidApp { return; } + this.refreshAdminPaneState(); + this.selectProfilePane("account"); this.populateProfileRelays(); this.populateBlockedList(); @@ -3670,6 +3821,384 @@ class bitvidApp { }); } + refreshAdminPaneState() { + accessControl.refresh(); + const adminNav = this.profileNavButtons.admin; + const adminPane = this.profilePaneElements.admin; + + const actorNpub = this.getCurrentUserNpub(); + const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub); + const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub); + + if (adminNav instanceof HTMLElement) { + adminNav.classList.toggle("hidden", !canEdit); + if (!canEdit) { + adminNav.setAttribute("aria-selected", "false"); + } + } + + if (adminPane instanceof HTMLElement) { + adminPane.classList.toggle("hidden", !canEdit); + adminPane.setAttribute("aria-hidden", (!canEdit).toString()); + } + + if (!canEdit) { + this.clearAdminLists(); + if (typeof actorNpub !== "string" || !actorNpub) { + this.currentUserNpub = null; + } + if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) { + this.selectProfilePane("account"); + } + return; + } + + if (this.adminModeratorsSection instanceof HTMLElement) { + 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(); + } + + clearAdminLists() { + if (this.adminModeratorList) { + this.adminModeratorList.innerHTML = ""; + } + if (this.adminWhitelistList) { + this.adminWhitelistList.innerHTML = ""; + } + if (this.adminBlacklistList) { + this.adminBlacklistList.innerHTML = ""; + } + if (this.adminModeratorsEmpty instanceof HTMLElement) { + this.adminModeratorsEmpty.classList.remove("hidden"); + } + if (this.adminWhitelistEmpty instanceof HTMLElement) { + this.adminWhitelistEmpty.classList.remove("hidden"); + } + if (this.adminBlacklistEmpty instanceof HTMLElement) { + this.adminBlacklistEmpty.classList.remove("hidden"); + } + if (this.adminWhitelistModeToggle instanceof HTMLInputElement) { + this.adminWhitelistModeToggle.checked = accessControl.whitelistMode(); + } + } + + renderAdminList(listEl, emptyEl, entries, options = {}) { + if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) { + return; + } + + const { onRemove, removeLabel = "Remove", confirmMessage, removable = true } = + options; + + listEl.innerHTML = ""; + const values = Array.isArray(entries) ? [...entries] : []; + values.sort((a, b) => a.localeCompare(b)); + + if (!values.length) { + emptyEl.classList.remove("hidden"); + listEl.classList.add("hidden"); + return; + } + + emptyEl.classList.add("hidden"); + listEl.classList.remove("hidden"); + + values.forEach((npub) => { + const item = document.createElement("li"); + item.className = + "flex flex-col gap-2 rounded-lg bg-gray-800 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"; + + const label = document.createElement("p"); + label.className = "text-sm font-medium text-gray-100 break-all"; + label.textContent = npub; + item.appendChild(label); + + if (removable && typeof onRemove === "function") { + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = + "self-start rounded-md bg-gray-700 px-3 py-1 text-xs font-medium text-gray-100 transition hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"; + removeBtn.textContent = removeLabel; + removeBtn.addEventListener("click", () => { + if (confirmMessage) { + const message = confirmMessage.replace("{npub}", npub); + if (!window.confirm(message)) { + return; + } + } + removeBtn.disabled = true; + removeBtn.setAttribute("aria-busy", "true"); + onRemove(npub, removeBtn); + }); + item.appendChild(removeBtn); + } + + listEl.appendChild(item); + }); + } + + populateAdminLists() { + const actorNpub = this.getCurrentUserNpub(); + if (!actorNpub || !accessControl.canEditAdminLists(actorNpub)) { + this.clearAdminLists(); + return; + } + + const isSuperAdmin = accessControl.isSuperAdmin(actorNpub); + const editors = accessControl + .getEditors() + .filter((npub) => npub && npub !== ADMIN_SUPER_NPUB); + const whitelist = accessControl.getWhitelist(); + const blacklist = accessControl.getBlacklist(); + + this.renderAdminList(this.adminModeratorList, this.adminModeratorsEmpty, editors, { + onRemove: (npub, button) => this.handleRemoveModerator(npub, button), + removeLabel: "Remove", + confirmMessage: + "Remove moderator {npub}? They will immediately lose access to the admin panel.", + removable: isSuperAdmin, + }); + + this.renderAdminList(this.adminWhitelistList, this.adminWhitelistEmpty, whitelist, { + onRemove: (npub, button) => + this.handleAdminListMutation("whitelist", "remove", npub, button), + removeLabel: "Remove", + confirmMessage: "Remove {npub} from the whitelist?", + removable: true, + }); + + this.renderAdminList(this.adminBlacklistList, this.adminBlacklistEmpty, blacklist, { + onRemove: (npub, button) => + this.handleAdminListMutation("blacklist", "remove", npub, button), + removeLabel: "Unblock", + confirmMessage: "Remove {npub} from the blacklist?", + removable: true, + }); + } + + getCurrentUserNpub() { + if (typeof this.currentUserNpub === "string" && this.currentUserNpub) { + return this.currentUserNpub; + } + + if (!this.pubkey) { + return null; + } + + const encoded = this.safeEncodeNpub(this.pubkey); + if (encoded) { + this.currentUserNpub = encoded; + } + return this.currentUserNpub; + } + + ensureAdminActor(requireSuperAdmin = false) { + const actorNpub = this.getCurrentUserNpub(); + if (!actorNpub) { + this.showError("Please login with a Nostr account to manage admin settings."); + return null; + } + if (!accessControl.canEditAdminLists(actorNpub)) { + this.showError("You do not have permission to manage BitVid moderation lists."); + return null; + } + if (requireSuperAdmin && !accessControl.isSuperAdmin(actorNpub)) { + this.showError("Only the Super Admin can manage moderators or whitelist mode."); + return null; + } + return actorNpub; + } + + handleAddModerator() { + const actorNpub = this.ensureAdminActor(true); + if (!actorNpub || !this.adminModeratorInput) { + return; + } + + const value = this.adminModeratorInput.value.trim(); + if (!value) { + this.showError("Enter an npub to add as a moderator."); + return; + } + + if (this.adminAddModeratorBtn) { + this.adminAddModeratorBtn.disabled = true; + this.adminAddModeratorBtn.setAttribute("aria-busy", "true"); + } + + try { + const result = accessControl.addModerator(actorNpub, value); + if (!result.ok) { + this.showError(this.describeAdminError(result.error)); + return; + } + + this.adminModeratorInput.value = ""; + this.showSuccess("Moderator added successfully."); + this.onAccessControlUpdated(); + } finally { + if (this.adminAddModeratorBtn) { + this.adminAddModeratorBtn.disabled = false; + this.adminAddModeratorBtn.removeAttribute("aria-busy"); + } + } + } + + handleRemoveModerator(npub, button) { + const actorNpub = this.ensureAdminActor(true); + if (!actorNpub) { + if (button instanceof HTMLElement) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + + const result = accessControl.removeModerator(actorNpub, npub); + if (!result.ok) { + this.showError(this.describeAdminError(result.error)); + if (button instanceof HTMLElement) { + button.disabled = false; + button.removeAttribute("aria-busy"); + } + return; + } + + this.showSuccess("Moderator removed."); + this.onAccessControlUpdated(); + } + + handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { + const actorNpub = this.ensureAdminActor(false); + if (!actorNpub) { + if (sourceButton instanceof HTMLElement) { + sourceButton.disabled = false; + sourceButton.removeAttribute("aria-busy"); + } + return; + } + + const isWhitelist = listType === "whitelist"; + const input = isWhitelist ? this.adminWhitelistInput : this.adminBlacklistInput; + const addButton = isWhitelist ? this.adminAddWhitelistBtn : this.adminAddBlacklistBtn; + const isAdd = action === "add"; + + let target = typeof explicitNpub === "string" ? explicitNpub.trim() : ""; + if (!target && input instanceof HTMLInputElement) { + target = input.value.trim(); + } + + if (isAdd && !target) { + this.showError("Enter an npub before adding it to the list."); + if (sourceButton instanceof HTMLElement) { + sourceButton.disabled = false; + sourceButton.removeAttribute("aria-busy"); + } + return; + } + + const buttonToToggle = sourceButton || (isAdd ? addButton : null); + if (buttonToToggle instanceof HTMLElement) { + buttonToToggle.disabled = true; + buttonToToggle.setAttribute("aria-busy", "true"); + } + + let result; + if (isWhitelist) { + result = isAdd + ? accessControl.addToWhitelist(actorNpub, target) + : accessControl.removeFromWhitelist(actorNpub, target); + } else { + result = isAdd + ? accessControl.addToBlacklist(actorNpub, target) + : accessControl.removeFromBlacklist(actorNpub, target); + } + + if (!result.ok) { + this.showError(this.describeAdminError(result.error)); + if (buttonToToggle instanceof HTMLElement) { + buttonToToggle.disabled = false; + buttonToToggle.removeAttribute("aria-busy"); + } + return; + } + + if (isAdd && input instanceof HTMLInputElement) { + input.value = ""; + } + + const successMessage = isWhitelist + ? isAdd + ? "Added to the whitelist." + : "Removed from the whitelist." + : isAdd + ? "Added to the blacklist." + : "Removed from the blacklist."; + this.showSuccess(successMessage); + this.onAccessControlUpdated(); + + if (buttonToToggle instanceof HTMLElement) { + buttonToToggle.disabled = false; + buttonToToggle.removeAttribute("aria-busy"); + } + } + + describeAdminError(code) { + switch (code) { + case "invalid npub": + return "Please provide a valid npub address."; + case "immutable": + return "That account cannot be modified."; + case "forbidden": + return "You do not have permission to perform that action."; + 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; + } + + 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); + }); + window.dispatchEvent(new CustomEvent("bitvid:access-control-updated")); + } + /** * Setup general event listeners for logout, modals, etc. */ @@ -4277,6 +4806,7 @@ class bitvidApp { console.log("[app.js] login() called with pubkey =", pubkey); this.pubkey = pubkey; + this.currentUserNpub = this.safeEncodeNpub(pubkey); let reloadScheduled = false; if (saveToStorage) { @@ -4293,6 +4823,8 @@ class bitvidApp { return; } + this.refreshAdminPaneState(); + // Hide login button if present if (this.loginButton) { this.loginButton.classList.add("hidden"); @@ -4336,6 +4868,7 @@ class bitvidApp { async logout() { nostrClient.logout(); this.pubkey = null; + this.currentUserNpub = null; // Show the login button again if (this.loginButton) { @@ -4374,6 +4907,8 @@ class bitvidApp { // Clear localStorage localStorage.removeItem("userPubKey"); + this.refreshAdminPaneState(); + // Refresh the video list so user sees only public videos again await this.loadVideos(); @@ -4811,6 +5346,8 @@ class bitvidApp { async loadVideos(forceFetch = false) { console.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); + accessControl.refresh(); + const shouldIncludeVideo = (video) => { if (!video || typeof video !== "object") { return false; @@ -4820,12 +5357,7 @@ class bitvidApp { return false; } - const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; - if (initialBlacklist.includes(authorNpub)) { - return false; - } - - if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { + if (!accessControl.canAccess(video)) { return false; } @@ -7117,14 +7649,16 @@ class bitvidApp { return; } + accessControl.refresh(); const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; - if (initialBlacklist.includes(authorNpub)) { - this.showError("This content has been removed or is not allowed."); - return; - } - - if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { - this.showError("This content is not from a whitelisted author."); + if (!accessControl.canAccess(authorNpub)) { + if (accessControl.isBlacklisted(authorNpub)) { + this.showError("This content has been removed or is not allowed."); + } else if (accessControl.whitelistMode()) { + this.showError("This content is not from a whitelisted author."); + } else { + this.showError("This content has been removed or is not allowed."); + } return; } @@ -7326,8 +7860,21 @@ class bitvidApp { * Simple helper to safely encode an npub. */ safeEncodeNpub(pubkey) { + if (typeof pubkey !== "string") { + return null; + } + + const trimmed = pubkey.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.startsWith("npub1")) { + return trimmed; + } + try { - return window.NostrTools.nip19.npubEncode(pubkey); + return window.NostrTools.nip19.npubEncode(trimmed); } catch (err) { return null; } diff --git a/js/channelProfile.js b/js/channelProfile.js index 6f5105a6..51719d31 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -8,8 +8,10 @@ import { app } from "./app.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; -import { initialBlacklist, initialWhitelist } from "./lists.js"; -import { isWhitelistEnabled } from "./config.js"; +import { accessControl } from "./accessControl.js"; + +let currentChannelHex = null; +let currentChannelNpub = null; let cachedZapButton = null; @@ -55,6 +57,9 @@ export async function initChannelProfileView() { return; } + currentChannelHex = null; + currentChannelNpub = null; + // 2) Decode npub => hex pubkey let hexPub; try { @@ -69,6 +74,9 @@ export async function initChannelProfileView() { return; } + currentChannelHex = hexPub; + currentChannelNpub = npub; + // 3) If user is logged in, load subscriptions and show sub/unsub button if (app.pubkey) { await subscriptions.loadSubscriptions(app.pubkey); @@ -243,6 +251,8 @@ async function loadUserProfile(pubkey) { */ async function loadUserVideos(pubkey) { try { + accessControl.refresh(); + // 1) Build filter for videos from this pubkey const filter = { kinds: [30078], @@ -279,11 +289,7 @@ async function loadUserVideos(pubkey) { if (app.blacklistedEventIds.has(video.id)) return false; // Author-level - const authorNpub = app.safeEncodeNpub(video.pubkey) || video.pubkey; - if (initialBlacklist.includes(authorNpub)) return false; - if (isWhitelistEnabled && !initialWhitelist.includes(authorNpub)) { - return false; - } + if (!accessControl.canAccess(video)) return false; return true; }); @@ -688,6 +694,26 @@ async function loadUserVideos(pubkey) { } } +window.addEventListener("bitvid:access-control-updated", () => { + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + if (hashParams.get("view") !== "channel-profile") { + return; + } + + if (!currentChannelHex) { + return; + } + + const activeNpub = hashParams.get("npub"); + if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) { + return; + } + + loadUserVideos(currentChannelHex).catch((error) => { + console.error("Failed to refresh channel videos after admin update:", error); + }); +}); + /** * Minimal placeholder for private video decryption. */ diff --git a/js/config.js b/js/config.js index 7363993b..f35124bc 100644 --- a/js/config.js +++ b/js/config.js @@ -1,4 +1,39 @@ // js/config.js export const isDevMode = true; // Set to false for production -export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users + +// ----------------------------------------------------------------------------- +// Admin governance (v1 local mode) +// ----------------------------------------------------------------------------- + +export const ADMIN_LIST_MODE = "local"; // Future: "nostr" +export const ADMIN_SUPER_NPUB = + "npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx"; +export const ADMIN_EDITORS_NPUBS = []; // Default moderators (optional) +export const ADMIN_LIST_NAMESPACE = "bitvid:admin"; // Reserved for Nostr lists + +const WHITELIST_MODE_KEY = "bitvid_admin_whitelist_mode"; +const DEFAULT_WHITELIST_ENABLED = false; + +export function getWhitelistMode() { + try { + const raw = localStorage.getItem(WHITELIST_MODE_KEY); + if (raw === null) { + return DEFAULT_WHITELIST_ENABLED; + } + return raw === "true"; + } catch (error) { + console.warn("Failed to read whitelist mode from storage:", error); + return DEFAULT_WHITELIST_ENABLED; + } +} + +export function setWhitelistMode(enabled) { + try { + localStorage.setItem(WHITELIST_MODE_KEY, enabled ? "true" : "false"); + } catch (error) { + console.warn("Failed to persist whitelist mode to storage:", error); + } +} + +export const ADMIN_WHITELIST_MODE_STORAGE_KEY = WHITELIST_MODE_KEY; diff --git a/js/lists.js b/js/lists.js index 2abc968e..8f77332b 100644 --- a/js/lists.js +++ b/js/lists.js @@ -19,8 +19,8 @@ const npubs = [ console.log("DEBUG: lists.js loaded, npubs:", npubs); // Blacklist of npubs that events will not be displayed in the bitvid official client -export const initialWhitelist = npubs; -export const initialBlacklist = [""]; +export const ADMIN_INITIAL_WHITELIST = npubs; +export const ADMIN_INITIAL_BLACKLIST = [""]; // Block specific events with the nevent -export const initialEventBlacklist = [""]; +export const ADMIN_INITIAL_EVENT_BLACKLIST = [""];