mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 10:52:27 +00:00
feat: add async admin list adapters
This commit is contained in:
1 parent
c8afe45545
commit
014b1369b0
4 files changed
+702
-130
No files matched your search
+147
-103
@@ -11,35 +11,13 @@ import {
|
||||
ADMIN_INITIAL_BLACKLIST,
|
||||
ADMIN_INITIAL_WHITELIST,
|
||||
} from "./lists.js";
|
||||
|
||||
const ADMIN_EDITORS_KEY = "bitvid_admin_editors";
|
||||
const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist";
|
||||
const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist";
|
||||
|
||||
const LEGACY_WHITELIST_KEY = "bitvid_whitelist";
|
||||
const LEGACY_BLACKLIST_KEY = "bitvid_blacklist";
|
||||
|
||||
function loadJSONList(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse list for ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJSONList(key, values) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(values));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to persist list for ${key}:`, error);
|
||||
}
|
||||
}
|
||||
import {
|
||||
ADMIN_BLACKLIST_KEY,
|
||||
ADMIN_EDITORS_KEY,
|
||||
ADMIN_WHITELIST_KEY,
|
||||
loadAdminState,
|
||||
persistAdminState,
|
||||
} from "./adminListStore.js";
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
@@ -57,26 +35,6 @@ function dedupeNpubs(values) {
|
||||
);
|
||||
}
|
||||
|
||||
function migrateLegacyList(targetKey, legacyKey, fallback) {
|
||||
const stored = loadJSONList(targetKey);
|
||||
if (stored !== null) {
|
||||
return dedupeNpubs(stored);
|
||||
}
|
||||
|
||||
const legacy = legacyKey ? loadJSONList(legacyKey) : null;
|
||||
if (legacy !== null) {
|
||||
const sanitized = dedupeNpubs(legacy);
|
||||
saveJSONList(targetKey, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const sanitizedFallback = dedupeNpubs(fallback);
|
||||
if (sanitizedFallback.length) {
|
||||
saveJSONList(targetKey, sanitizedFallback);
|
||||
}
|
||||
return sanitizedFallback;
|
||||
}
|
||||
|
||||
function isValidNpub(npub) {
|
||||
if (typeof npub !== "string") {
|
||||
return false;
|
||||
@@ -100,30 +58,77 @@ class AccessControl {
|
||||
this.whitelist = new Set();
|
||||
this.blacklist = new Set();
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.refresh();
|
||||
this.hasLoaded = false;
|
||||
this.lastError = null;
|
||||
this._isRefreshing = false;
|
||||
this._refreshPromise = this._performRefresh();
|
||||
this._refreshPromise.catch((error) => {
|
||||
console.error("Failed to load admin lists:", error);
|
||||
});
|
||||
}
|
||||
|
||||
async _performRefresh() {
|
||||
if (this._isRefreshing) {
|
||||
return this._refreshPromise;
|
||||
}
|
||||
|
||||
this._isRefreshing = true;
|
||||
try {
|
||||
const state = await loadAdminState();
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
const whitelist = Array.isArray(state?.whitelist)
|
||||
? state.whitelist
|
||||
: [];
|
||||
const blacklist = Array.isArray(state?.blacklist)
|
||||
? state.blacklist
|
||||
: [];
|
||||
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
this.whitelist = new Set(
|
||||
dedupeNpubs([...ADMIN_INITIAL_WHITELIST, ...whitelist])
|
||||
);
|
||||
this.blacklist = new Set(
|
||||
dedupeNpubs([...ADMIN_INITIAL_BLACKLIST, ...blacklist])
|
||||
);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = true;
|
||||
this.lastError = null;
|
||||
} catch (error) {
|
||||
this.lastError = error;
|
||||
if (!this.hasLoaded) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.blacklist.clear();
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
this._isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([
|
||||
...ADMIN_EDITORS_NPUBS,
|
||||
...migrateLegacyList(ADMIN_EDITORS_KEY, null, []),
|
||||
])
|
||||
);
|
||||
const promise = this._performRefresh();
|
||||
this._refreshPromise = promise;
|
||||
promise.catch((error) => {
|
||||
console.error("Failed to refresh admin lists:", error);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
this.whitelist = new Set(
|
||||
migrateLegacyList(ADMIN_WHITELIST_KEY, LEGACY_WHITELIST_KEY, [
|
||||
...ADMIN_INITIAL_WHITELIST,
|
||||
])
|
||||
);
|
||||
|
||||
this.blacklist = new Set(
|
||||
migrateLegacyList(ADMIN_BLACKLIST_KEY, LEGACY_BLACKLIST_KEY, [
|
||||
...ADMIN_INITIAL_BLACKLIST,
|
||||
])
|
||||
);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
async ensureReady() {
|
||||
try {
|
||||
await this._refreshPromise;
|
||||
} catch (error) {
|
||||
if (!this.hasLoaded) {
|
||||
await this.refresh();
|
||||
await this._refreshPromise;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
whitelistMode() {
|
||||
@@ -162,7 +167,7 @@ class AccessControl {
|
||||
return Array.from(this.editors);
|
||||
}
|
||||
|
||||
addModerator(requestorNpub, moderatorNpub) {
|
||||
async addModerator(requestorNpub, moderatorNpub) {
|
||||
if (!this.isSuperAdmin(requestorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -175,13 +180,18 @@ class AccessControl {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
|
||||
this.editors.add(normalized);
|
||||
saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
const nextEditors = dedupeNpubs([...this.getEditors(), normalized]);
|
||||
|
||||
try {
|
||||
await persistAdminState(requestorNpub, { editors: nextEditors });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
removeModerator(requestorNpub, moderatorNpub) {
|
||||
async removeModerator(requestorNpub, moderatorNpub) {
|
||||
if (!this.isSuperAdmin(requestorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -190,14 +200,18 @@ class AccessControl {
|
||||
if (!normalized || normalized === ADMIN_SUPER_NPUB) {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
const nextEditors = this.getEditors().filter((value) => value !== normalized);
|
||||
|
||||
this.editors.delete(normalized);
|
||||
saveJSONList(ADMIN_EDITORS_KEY, this.getEditors());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
try {
|
||||
await persistAdminState(requestorNpub, { editors: nextEditors });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
addToWhitelist(actorNpub, targetNpub) {
|
||||
async addToWhitelist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -210,15 +224,24 @@ class AccessControl {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
|
||||
this.blacklist.delete(normalized);
|
||||
this.whitelist.add(normalized);
|
||||
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
|
||||
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
const nextWhitelist = dedupeNpubs([...this.getWhitelist(), normalized]);
|
||||
const nextBlacklist = this.getBlacklist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, {
|
||||
whitelist: nextWhitelist,
|
||||
blacklist: nextBlacklist,
|
||||
});
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
removeFromWhitelist(actorNpub, targetNpub) {
|
||||
async removeFromWhitelist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -227,14 +250,20 @@ class AccessControl {
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
const nextWhitelist = this.getWhitelist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
this.whitelist.delete(normalized);
|
||||
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
try {
|
||||
await persistAdminState(actorNpub, { whitelist: nextWhitelist });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
addToBlacklist(actorNpub, targetNpub) {
|
||||
async addToBlacklist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -251,15 +280,24 @@ class AccessControl {
|
||||
return { ok: false, error: "immutable" };
|
||||
}
|
||||
|
||||
this.whitelist.delete(normalized);
|
||||
this.blacklist.add(normalized);
|
||||
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
|
||||
saveJSONList(ADMIN_WHITELIST_KEY, this.getWhitelist());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
const nextBlacklist = dedupeNpubs([...this.getBlacklist(), normalized]);
|
||||
const nextWhitelist = this.getWhitelist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
try {
|
||||
await persistAdminState(actorNpub, {
|
||||
blacklist: nextBlacklist,
|
||||
whitelist: nextWhitelist,
|
||||
});
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
removeFromBlacklist(actorNpub, targetNpub) {
|
||||
async removeFromBlacklist(actorNpub, targetNpub) {
|
||||
if (!this.canEditAdminLists(actorNpub)) {
|
||||
return { ok: false, error: "forbidden" };
|
||||
}
|
||||
@@ -268,11 +306,17 @@ class AccessControl {
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "invalid npub" };
|
||||
}
|
||||
const nextBlacklist = this.getBlacklist().filter(
|
||||
(value) => value !== normalized
|
||||
);
|
||||
|
||||
this.blacklist.delete(normalized);
|
||||
saveJSONList(ADMIN_BLACKLIST_KEY, this.getBlacklist());
|
||||
this.refresh();
|
||||
return { ok: true };
|
||||
try {
|
||||
await persistAdminState(actorNpub, { blacklist: nextBlacklist });
|
||||
await this.refresh();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error?.code || "storage-error" };
|
||||
}
|
||||
}
|
||||
|
||||
setWhitelistMode(actorNpub, enabled) {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// js/adminListStore.js
|
||||
|
||||
import {
|
||||
ADMIN_LIST_MODE,
|
||||
ADMIN_LIST_NAMESPACE,
|
||||
ADMIN_SUPER_NPUB,
|
||||
ADMIN_EDITORS_NPUBS,
|
||||
isDevMode,
|
||||
} from "./config.js";
|
||||
import {
|
||||
ADMIN_INITIAL_BLACKLIST,
|
||||
ADMIN_INITIAL_WHITELIST,
|
||||
} from "./lists.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
|
||||
export const ADMIN_EDITORS_KEY = "bitvid_admin_editors";
|
||||
export const ADMIN_WHITELIST_KEY = "bitvid_admin_whitelist";
|
||||
export const ADMIN_BLACKLIST_KEY = "bitvid_admin_blacklist";
|
||||
|
||||
const LEGACY_WHITELIST_KEY = "bitvid_whitelist";
|
||||
const LEGACY_BLACKLIST_KEY = "bitvid_blacklist";
|
||||
|
||||
const LIST_IDENTIFIERS = {
|
||||
editors: "editors",
|
||||
whitelist: "whitelist",
|
||||
blacklist: "blacklist",
|
||||
};
|
||||
|
||||
function createError(code, message, cause) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
if (cause) {
|
||||
error.cause = cause;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeNpub(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function dedupeNpubs(values) {
|
||||
const normalized = Array.isArray(values) ? values.map(normalizeNpub) : [];
|
||||
return Array.from(
|
||||
normalized.reduce((set, npub) => {
|
||||
if (npub) {
|
||||
set.add(npub);
|
||||
}
|
||||
return set;
|
||||
}, new Set())
|
||||
);
|
||||
}
|
||||
|
||||
function loadJSONList(key) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse list for ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveJSONList(key, values) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(values));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to persist list for ${key}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyList(targetKey, legacyKey, fallback) {
|
||||
const stored = loadJSONList(targetKey);
|
||||
if (stored !== null) {
|
||||
return dedupeNpubs(stored);
|
||||
}
|
||||
|
||||
const legacy = legacyKey ? loadJSONList(legacyKey) : null;
|
||||
if (legacy !== null) {
|
||||
const sanitized = dedupeNpubs(legacy);
|
||||
saveJSONList(targetKey, sanitized);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const sanitizedFallback = dedupeNpubs(fallback);
|
||||
if (sanitizedFallback.length) {
|
||||
saveJSONList(targetKey, sanitizedFallback);
|
||||
}
|
||||
return sanitizedFallback;
|
||||
}
|
||||
|
||||
async function loadLocalState() {
|
||||
return {
|
||||
editors: migrateLegacyList(ADMIN_EDITORS_KEY, null, ADMIN_EDITORS_NPUBS),
|
||||
whitelist: migrateLegacyList(
|
||||
ADMIN_WHITELIST_KEY,
|
||||
LEGACY_WHITELIST_KEY,
|
||||
ADMIN_INITIAL_WHITELIST
|
||||
),
|
||||
blacklist: migrateLegacyList(
|
||||
ADMIN_BLACKLIST_KEY,
|
||||
LEGACY_BLACKLIST_KEY,
|
||||
ADMIN_INITIAL_BLACKLIST
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function persistLocalState(_actorNpub, updates = {}) {
|
||||
if (updates.editors) {
|
||||
saveJSONList(ADMIN_EDITORS_KEY, dedupeNpubs(updates.editors));
|
||||
}
|
||||
if (updates.whitelist) {
|
||||
saveJSONList(ADMIN_WHITELIST_KEY, dedupeNpubs(updates.whitelist));
|
||||
}
|
||||
if (updates.blacklist) {
|
||||
saveJSONList(ADMIN_BLACKLIST_KEY, dedupeNpubs(updates.blacklist));
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNostrReady() {
|
||||
if (!nostrClient || !nostrClient.pool) {
|
||||
throw createError(
|
||||
"nostr-unavailable",
|
||||
"Nostr relay pool is not initialized."
|
||||
);
|
||||
}
|
||||
|
||||
const relays = Array.isArray(nostrClient.relays) ? nostrClient.relays : [];
|
||||
if (!relays.length) {
|
||||
throw createError("nostr-unavailable", "No Nostr relays configured.");
|
||||
}
|
||||
|
||||
return relays;
|
||||
}
|
||||
|
||||
function decodeNpubToHex(npub) {
|
||||
const trimmed = normalizeNpub(npub);
|
||||
if (!trimmed) {
|
||||
throw createError("invalid npub", "Empty npub provided.");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = window?.NostrTools?.nip19?.decode(trimmed);
|
||||
if (decoded?.type !== "npub" || !decoded?.data) {
|
||||
throw new Error("Invalid npub format");
|
||||
}
|
||||
return typeof decoded.data === "string"
|
||||
? decoded.data
|
||||
: decoded.data?.pubkey || "";
|
||||
} catch (error) {
|
||||
throw createError("invalid npub", "Unable to decode npub.", error);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHexToNpub(hex) {
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.npubEncode(hex) || "";
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("Failed to encode hex pubkey to npub:", error);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractNpubsFromEvent(event) {
|
||||
if (!event || !Array.isArray(event.tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hexKeys = event.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "p" && tag[1])
|
||||
.map((tag) => tag[1]);
|
||||
|
||||
const npubs = hexKeys
|
||||
.map((hex) => encodeHexToNpub(hex))
|
||||
.filter((npub) => typeof npub === "string" && npub);
|
||||
|
||||
return dedupeNpubs(npubs);
|
||||
}
|
||||
|
||||
async function loadNostrList(identifier) {
|
||||
const relays = ensureNostrReady();
|
||||
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
|
||||
const filter = {
|
||||
kinds: [30000],
|
||||
"#d": [dTagValue],
|
||||
limit: 1,
|
||||
};
|
||||
|
||||
const perRelay = await Promise.all(
|
||||
relays.map(async (url) => {
|
||||
try {
|
||||
const events = await nostrClient.pool.list([url], [filter]);
|
||||
return Array.isArray(events) ? events : [];
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(`[adminListStore] Relay fetch failed for ${url}:`, error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const events = perRelay.flat();
|
||||
if (!events.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newest = events.reduce((latest, event) => {
|
||||
if (!latest) {
|
||||
return event;
|
||||
}
|
||||
if (event.created_at === latest.created_at) {
|
||||
return event.id > latest.id ? event : latest;
|
||||
}
|
||||
return event.created_at > latest.created_at ? event : latest;
|
||||
}, null);
|
||||
|
||||
return extractNpubsFromEvent(newest);
|
||||
}
|
||||
|
||||
async function loadNostrState() {
|
||||
const editors = await loadNostrList(LIST_IDENTIFIERS.editors);
|
||||
const whitelist = await loadNostrList(LIST_IDENTIFIERS.whitelist);
|
||||
const blacklist = await loadNostrList(LIST_IDENTIFIERS.blacklist);
|
||||
|
||||
return {
|
||||
editors,
|
||||
whitelist,
|
||||
blacklist,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistNostrState(actorNpub, updates = {}) {
|
||||
const relays = ensureNostrReady();
|
||||
const extension = window?.nostr;
|
||||
if (!extension || typeof extension.signEvent !== "function") {
|
||||
throw createError(
|
||||
"nostr-extension-missing",
|
||||
"A Nostr extension with signEvent support is required."
|
||||
);
|
||||
}
|
||||
|
||||
const actorHex = decodeNpubToHex(actorNpub);
|
||||
if (!actorHex) {
|
||||
throw createError("invalid npub", "Unable to decode the actor npub.");
|
||||
}
|
||||
|
||||
const entries = Object.entries(updates).filter(([key, value]) => {
|
||||
return key in LIST_IDENTIFIERS && Array.isArray(value);
|
||||
});
|
||||
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const [listKey, npubs] of entries) {
|
||||
const identifier = LIST_IDENTIFIERS[listKey];
|
||||
const dTagValue = `${ADMIN_LIST_NAMESPACE}:${identifier}`;
|
||||
|
||||
const hexPubkeys = Array.from(
|
||||
new Set(
|
||||
npubs.map((npub) => decodeNpubToHex(npub)).filter((hex) => !!hex)
|
||||
)
|
||||
);
|
||||
|
||||
const tags = [["d", dTagValue]];
|
||||
hexPubkeys.forEach((hex) => {
|
||||
tags.push(["p", hex]);
|
||||
});
|
||||
|
||||
// Explicitly keep the super admin on the whitelist and editors lists when publishing.
|
||||
if (listKey === "whitelist" || listKey === "editors") {
|
||||
try {
|
||||
const superHex = decodeNpubToHex(ADMIN_SUPER_NPUB);
|
||||
if (superHex && !hexPubkeys.includes(superHex)) {
|
||||
tags.push(["p", superHex]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn("Failed to ensure super admin presence:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 30000,
|
||||
pubkey: actorHex,
|
||||
created_at: now,
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
|
||||
let signedEvent;
|
||||
try {
|
||||
signedEvent = await extension.signEvent(event);
|
||||
} catch (error) {
|
||||
throw createError("signature-failed", "Failed to sign admin list event.", error);
|
||||
}
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relays.map(async (url) => {
|
||||
try {
|
||||
await nostrClient.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`[adminListStore] Published ${listKey} list to ${url}`);
|
||||
}
|
||||
return { url, success: true };
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
`[adminListStore] Failed to publish ${listKey} list to ${url}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
return { url, success: false, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (!publishResults.some((result) => result.success)) {
|
||||
throw createError(
|
||||
"publish-failed",
|
||||
`Failed to publish ${listKey} list to any relay.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAdminState() {
|
||||
if (ADMIN_LIST_MODE === "nostr") {
|
||||
return loadNostrState();
|
||||
}
|
||||
return loadLocalState();
|
||||
}
|
||||
|
||||
export async function persistAdminState(actorNpub, updates) {
|
||||
if (ADMIN_LIST_MODE === "nostr") {
|
||||
return persistNostrState(actorNpub, updates);
|
||||
}
|
||||
return persistLocalState(actorNpub, updates);
|
||||
}
|
||||
@@ -900,6 +900,12 @@ class bitvidApp {
|
||||
// 4. Connect to Nostr
|
||||
await nostrClient.init();
|
||||
|
||||
try {
|
||||
await accessControl.refresh();
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh admin lists after connecting to Nostr:", error);
|
||||
}
|
||||
|
||||
// Grab the "Subscriptions" link by its id in the sidebar
|
||||
this.subscriptionsLink = document.getElementById("subscriptionsLink");
|
||||
|
||||
@@ -3467,7 +3473,7 @@ class bitvidApp {
|
||||
this.selectProfilePane("account");
|
||||
this.populateProfileRelays();
|
||||
this.populateBlockedList();
|
||||
this.refreshAdminPaneState();
|
||||
await this.refreshAdminPaneState();
|
||||
|
||||
console.log("Profile modal initialization successful");
|
||||
return true;
|
||||
@@ -3551,12 +3557,16 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
openProfileModal() {
|
||||
async openProfileModal() {
|
||||
if (!this.profileModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshAdminPaneState();
|
||||
try {
|
||||
await this.refreshAdminPaneState();
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh admin pane while opening profile modal:", error);
|
||||
}
|
||||
|
||||
this.selectProfilePane("account");
|
||||
this.populateProfileRelays();
|
||||
@@ -3821,11 +3831,18 @@ class bitvidApp {
|
||||
});
|
||||
}
|
||||
|
||||
refreshAdminPaneState() {
|
||||
accessControl.refresh();
|
||||
async refreshAdminPaneState() {
|
||||
const adminNav = this.profileNavButtons.admin;
|
||||
const adminPane = this.profilePaneElements.admin;
|
||||
|
||||
let loadError = null;
|
||||
this.setAdminLoading(true);
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
loadError = error;
|
||||
}
|
||||
|
||||
const actorNpub = this.getCurrentUserNpub();
|
||||
const canEdit = !!actorNpub && accessControl.canEditAdminLists(actorNpub);
|
||||
const isSuperAdmin = !!actorNpub && accessControl.isSuperAdmin(actorNpub);
|
||||
@@ -3842,6 +3859,18 @@ class bitvidApp {
|
||||
adminPane.setAttribute("aria-hidden", (!canEdit).toString());
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
console.error("Failed to load admin lists:", loadError);
|
||||
const message =
|
||||
loadError?.code === "nostr-unavailable"
|
||||
? "Unable to reach Nostr relays. Moderation lists may be out of date."
|
||||
: "Unable to load moderation lists. Please try again.";
|
||||
this.showError(message);
|
||||
this.clearAdminLists();
|
||||
this.setAdminLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canEdit) {
|
||||
this.clearAdminLists();
|
||||
if (typeof actorNpub !== "string" || !actorNpub) {
|
||||
@@ -3850,6 +3879,7 @@ class bitvidApp {
|
||||
if (adminNav instanceof HTMLElement && adminNav.classList.contains("bg-gray-800")) {
|
||||
this.selectProfilePane("account");
|
||||
}
|
||||
this.setAdminLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3865,9 +3895,49 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
this.populateAdminLists();
|
||||
this.setAdminLoading(false);
|
||||
}
|
||||
|
||||
storeAdminEmptyMessages() {
|
||||
const capture = (element) => {
|
||||
if (element instanceof HTMLElement && !element.dataset.defaultMessage) {
|
||||
element.dataset.defaultMessage = element.textContent || "";
|
||||
}
|
||||
};
|
||||
|
||||
capture(this.adminModeratorsEmpty);
|
||||
capture(this.adminWhitelistEmpty);
|
||||
capture(this.adminBlacklistEmpty);
|
||||
}
|
||||
|
||||
setAdminLoading(isLoading) {
|
||||
this.storeAdminEmptyMessages();
|
||||
if (this.profilePaneElements.admin instanceof HTMLElement) {
|
||||
this.profilePaneElements.admin.setAttribute(
|
||||
"aria-busy",
|
||||
isLoading ? "true" : "false"
|
||||
);
|
||||
}
|
||||
|
||||
const toggleMessage = (element, message) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
if (isLoading) {
|
||||
element.textContent = message;
|
||||
element.classList.remove("hidden");
|
||||
} else {
|
||||
element.textContent = element.dataset.defaultMessage || element.textContent;
|
||||
}
|
||||
};
|
||||
|
||||
toggleMessage(this.adminModeratorsEmpty, "Loading moderators…");
|
||||
toggleMessage(this.adminWhitelistEmpty, "Loading whitelist…");
|
||||
toggleMessage(this.adminBlacklistEmpty, "Loading blacklist…");
|
||||
}
|
||||
|
||||
clearAdminLists() {
|
||||
this.storeAdminEmptyMessages();
|
||||
if (this.adminModeratorList) {
|
||||
this.adminModeratorList.innerHTML = "";
|
||||
}
|
||||
@@ -3878,12 +3948,21 @@ class bitvidApp {
|
||||
this.adminBlacklistList.innerHTML = "";
|
||||
}
|
||||
if (this.adminModeratorsEmpty instanceof HTMLElement) {
|
||||
this.adminModeratorsEmpty.textContent =
|
||||
this.adminModeratorsEmpty.dataset.defaultMessage ||
|
||||
this.adminModeratorsEmpty.textContent;
|
||||
this.adminModeratorsEmpty.classList.remove("hidden");
|
||||
}
|
||||
if (this.adminWhitelistEmpty instanceof HTMLElement) {
|
||||
this.adminWhitelistEmpty.textContent =
|
||||
this.adminWhitelistEmpty.dataset.defaultMessage ||
|
||||
this.adminWhitelistEmpty.textContent;
|
||||
this.adminWhitelistEmpty.classList.remove("hidden");
|
||||
}
|
||||
if (this.adminBlacklistEmpty instanceof HTMLElement) {
|
||||
this.adminBlacklistEmpty.textContent =
|
||||
this.adminBlacklistEmpty.dataset.defaultMessage ||
|
||||
this.adminBlacklistEmpty.textContent;
|
||||
this.adminBlacklistEmpty.classList.remove("hidden");
|
||||
}
|
||||
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
|
||||
@@ -4018,7 +4097,20 @@ class bitvidApp {
|
||||
return actorNpub;
|
||||
}
|
||||
|
||||
handleAddModerator() {
|
||||
async handleAddModerator() {
|
||||
let preloadError = null;
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
preloadError = error;
|
||||
console.error("Failed to load admin lists before adding moderator:", error);
|
||||
}
|
||||
|
||||
if (preloadError) {
|
||||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const actorNpub = this.ensureAdminActor(true);
|
||||
if (!actorNpub || !this.adminModeratorInput) {
|
||||
return;
|
||||
@@ -4036,7 +4128,7 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = accessControl.addModerator(actorNpub, value);
|
||||
const result = await accessControl.addModerator(actorNpub, value);
|
||||
if (!result.ok) {
|
||||
this.showError(this.describeAdminError(result.error));
|
||||
return;
|
||||
@@ -4044,7 +4136,7 @@ class bitvidApp {
|
||||
|
||||
this.adminModeratorInput.value = "";
|
||||
this.showSuccess("Moderator added successfully.");
|
||||
this.onAccessControlUpdated();
|
||||
await this.onAccessControlUpdated();
|
||||
} finally {
|
||||
if (this.adminAddModeratorBtn) {
|
||||
this.adminAddModeratorBtn.disabled = false;
|
||||
@@ -4053,7 +4145,24 @@ class bitvidApp {
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveModerator(npub, button) {
|
||||
async handleRemoveModerator(npub, button) {
|
||||
let preloadError = null;
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
preloadError = error;
|
||||
console.error("Failed to load admin lists before removing moderator:", error);
|
||||
}
|
||||
|
||||
if (preloadError) {
|
||||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||||
if (button instanceof HTMLElement) {
|
||||
button.disabled = false;
|
||||
button.removeAttribute("aria-busy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actorNpub = this.ensureAdminActor(true);
|
||||
if (!actorNpub) {
|
||||
if (button instanceof HTMLElement) {
|
||||
@@ -4063,7 +4172,7 @@ class bitvidApp {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = accessControl.removeModerator(actorNpub, npub);
|
||||
const result = await accessControl.removeModerator(actorNpub, npub);
|
||||
if (!result.ok) {
|
||||
this.showError(this.describeAdminError(result.error));
|
||||
if (button instanceof HTMLElement) {
|
||||
@@ -4074,10 +4183,27 @@ class bitvidApp {
|
||||
}
|
||||
|
||||
this.showSuccess("Moderator removed.");
|
||||
this.onAccessControlUpdated();
|
||||
await this.onAccessControlUpdated();
|
||||
}
|
||||
|
||||
handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
|
||||
async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
|
||||
let preloadError = null;
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
preloadError = error;
|
||||
console.error("Failed to load admin lists before updating entries:", error);
|
||||
}
|
||||
|
||||
if (preloadError) {
|
||||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||||
if (sourceButton instanceof HTMLElement) {
|
||||
sourceButton.disabled = false;
|
||||
sourceButton.removeAttribute("aria-busy");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actorNpub = this.ensureAdminActor(false);
|
||||
if (!actorNpub) {
|
||||
if (sourceButton instanceof HTMLElement) {
|
||||
@@ -4115,12 +4241,12 @@ class bitvidApp {
|
||||
let result;
|
||||
if (isWhitelist) {
|
||||
result = isAdd
|
||||
? accessControl.addToWhitelist(actorNpub, target)
|
||||
: accessControl.removeFromWhitelist(actorNpub, target);
|
||||
? await accessControl.addToWhitelist(actorNpub, target)
|
||||
: await accessControl.removeFromWhitelist(actorNpub, target);
|
||||
} else {
|
||||
result = isAdd
|
||||
? accessControl.addToBlacklist(actorNpub, target)
|
||||
: accessControl.removeFromBlacklist(actorNpub, target);
|
||||
? await accessControl.addToBlacklist(actorNpub, target)
|
||||
: await accessControl.removeFromBlacklist(actorNpub, target);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -4144,7 +4270,7 @@ class bitvidApp {
|
||||
? "Added to the blacklist."
|
||||
: "Removed from the blacklist.";
|
||||
this.showSuccess(successMessage);
|
||||
this.onAccessControlUpdated();
|
||||
await this.onAccessControlUpdated();
|
||||
|
||||
if (buttonToToggle instanceof HTMLElement) {
|
||||
buttonToToggle.disabled = false;
|
||||
@@ -4160,12 +4286,38 @@ class bitvidApp {
|
||||
return "That account cannot be modified.";
|
||||
case "forbidden":
|
||||
return "You do not have permission to perform that action.";
|
||||
case "nostr-unavailable":
|
||||
return "Unable to reach the configured Nostr relays. Please retry once your connection is restored.";
|
||||
case "nostr-extension-missing":
|
||||
return "Connect a Nostr extension before editing moderation lists.";
|
||||
case "signature-failed":
|
||||
return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again.";
|
||||
case "publish-failed":
|
||||
return "Failed to publish the update to Nostr relays. Please try again.";
|
||||
case "storage-error":
|
||||
return "Unable to update moderation settings. Please try again.";
|
||||
default:
|
||||
return "Unable to update moderation settings. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
handleWhitelistModeToggle(enabled) {
|
||||
async handleWhitelistModeToggle(enabled) {
|
||||
let preloadError = null;
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
preloadError = error;
|
||||
console.error("Failed to load admin lists before toggling whitelist mode:", error);
|
||||
}
|
||||
|
||||
if (preloadError) {
|
||||
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
|
||||
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
|
||||
this.adminWhitelistModeToggle.checked = accessControl.whitelistMode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const actorNpub = this.ensureAdminActor(true);
|
||||
if (!actorNpub) {
|
||||
if (this.adminWhitelistModeToggle instanceof HTMLInputElement) {
|
||||
@@ -4188,11 +4340,16 @@ class bitvidApp {
|
||||
? "Whitelist mode enabled. Only approved creators will appear in feeds."
|
||||
: "Whitelist mode disabled. All creators except those blacklisted will appear."
|
||||
);
|
||||
this.onAccessControlUpdated();
|
||||
await this.onAccessControlUpdated();
|
||||
}
|
||||
|
||||
onAccessControlUpdated() {
|
||||
this.refreshAdminPaneState();
|
||||
async onAccessControlUpdated() {
|
||||
try {
|
||||
await this.refreshAdminPaneState();
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh admin pane after update:", error);
|
||||
}
|
||||
|
||||
this.loadVideos(true).catch((error) => {
|
||||
console.error("Failed to refresh videos after admin update:", error);
|
||||
});
|
||||
@@ -4213,7 +4370,9 @@ class bitvidApp {
|
||||
// 2) Profile button
|
||||
if (this.profileButton) {
|
||||
this.profileButton.addEventListener("click", () => {
|
||||
this.openProfileModal();
|
||||
this.openProfileModal().catch((error) => {
|
||||
console.error("Failed to open profile modal:", error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4823,7 +4982,7 @@ class bitvidApp {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshAdminPaneState();
|
||||
await this.refreshAdminPaneState();
|
||||
|
||||
// Hide login button if present
|
||||
if (this.loginButton) {
|
||||
@@ -4907,7 +5066,7 @@ class bitvidApp {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem("userPubKey");
|
||||
|
||||
this.refreshAdminPaneState();
|
||||
await this.refreshAdminPaneState();
|
||||
|
||||
// Refresh the video list so user sees only public videos again
|
||||
await this.loadVideos();
|
||||
@@ -5346,7 +5505,11 @@ class bitvidApp {
|
||||
async loadVideos(forceFetch = false) {
|
||||
console.log("Starting loadVideos... (forceFetch =", forceFetch, ")");
|
||||
|
||||
accessControl.refresh();
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn("Failed to ensure admin lists were loaded before fetching videos:", error);
|
||||
}
|
||||
|
||||
const shouldIncludeVideo = (video) => {
|
||||
if (!video || typeof video !== "object") {
|
||||
@@ -7649,7 +7812,11 @@ class bitvidApp {
|
||||
return;
|
||||
}
|
||||
|
||||
accessControl.refresh();
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn("Failed to ensure admin lists were loaded before playback:", error);
|
||||
}
|
||||
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
|
||||
if (!accessControl.canAccess(authorNpub)) {
|
||||
if (accessControl.isBlacklisted(authorNpub)) {
|
||||
|
||||
@@ -251,7 +251,11 @@ async function loadUserProfile(pubkey) {
|
||||
*/
|
||||
async function loadUserVideos(pubkey) {
|
||||
try {
|
||||
accessControl.refresh();
|
||||
try {
|
||||
await accessControl.ensureReady();
|
||||
} catch (error) {
|
||||
console.warn("Failed to ensure admin lists were loaded before channel fetch:", error);
|
||||
}
|
||||
|
||||
// 1) Build filter for videos from this pubkey
|
||||
const filter = {
|
||||
|
||||
Reference in new issue
Block a user