mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #140 from PR0M3TH3AN/codex/add-super-admin-and-moderator-roles
Add Super Admin moderation controls and centralized access checks
This commit is contained in:
6 files changed
+1086
-227
No files matched your search
@@ -88,6 +88,15 @@
|
||||
>
|
||||
Blocked
|
||||
</button>
|
||||
<button
|
||||
id="profileNavAdmin"
|
||||
type="button"
|
||||
class="profile-nav-button px-4 py-2 rounded-md text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 hidden"
|
||||
aria-controls="profilePaneAdmin"
|
||||
aria-selected="false"
|
||||
>
|
||||
Admin (Moderation)
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Right content panes -->
|
||||
@@ -176,6 +185,142 @@
|
||||
</div>
|
||||
<ul id="blockedList" class="space-y-3"></ul>
|
||||
</section>
|
||||
<section
|
||||
id="profilePaneAdmin"
|
||||
class="profile-pane hidden space-y-6"
|
||||
role="tabpanel"
|
||||
aria-labelledby="profileNavAdmin"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-white">Platform moderation</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
Manage BitVid’s moderator roles and creator allow/block lists. Changes apply instantly across the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="adminModeratorsSection" class="space-y-4 hidden" aria-hidden="true">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Moderators</h4>
|
||||
<p class="text-xs uppercase tracking-wide text-gray-400">
|
||||
Super Admin only
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Moderators can edit the whitelist and blacklist but cannot manage other moderators or whitelist mode.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminModeratorInput">
|
||||
<span class="mb-2 block font-medium">Moderator npub</span>
|
||||
<input
|
||||
id="adminModeratorInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddModeratorBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add moderator
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminModeratorsEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No moderators configured yet.
|
||||
</div>
|
||||
<ul id="adminModeratorList" class="space-y-3"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminWhitelistSection" class="space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Whitelist</h4>
|
||||
<label
|
||||
id="adminWhitelistModeWrapper"
|
||||
class="flex items-center gap-2 text-xs font-medium text-gray-300 hidden"
|
||||
for="adminWhitelistModeToggle"
|
||||
>
|
||||
<input
|
||||
id="adminWhitelistModeToggle"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span>Enable whitelist-only mode</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Creators listed here remain visible when whitelist mode is enabled. Adding someone removes them from the blacklist.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminWhitelistInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="adminWhitelistInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddWhitelistBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add to whitelist
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminWhitelistEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No whitelisted creators yet.
|
||||
</div>
|
||||
<ul id="adminWhitelistList" class="space-y-3"></ul>
|
||||
</div>
|
||||
|
||||
<div id="adminBlacklistSection" class="space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h4 class="text-base font-semibold text-white">Blacklist</h4>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Creators on the blacklist are hidden from BitVid everywhere. Adding someone removes them from the whitelist.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<label class="flex-1 text-sm text-gray-300" for="adminBlacklistInput">
|
||||
<span class="mb-2 block font-medium">Creator npub</span>
|
||||
<input
|
||||
id="adminBlacklistInput"
|
||||
type="text"
|
||||
inputmode="text"
|
||||
autocomplete="off"
|
||||
class="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="npub1..."
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="adminAddBlacklistBtn"
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-md bg-blue-600 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
|
||||
>
|
||||
Add to blacklist
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="adminBlacklistEmpty"
|
||||
class="rounded-lg border border-dashed border-gray-700 p-4 text-center text-sm text-gray-400"
|
||||
>
|
||||
No blacklisted creators yet.
|
||||
</div>
|
||||
<ul id="adminBlacklistList" class="space-y-3"></ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+296
-190
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+33
-7
@@ -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.
|
||||
*/
|
||||
|
||||
+36
-1
@@ -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;
|
||||
+3
-3
@@ -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 = [""];
|
||||
Reference in new issue
Block a user