mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-06 12:52:26 +00:00
Merge pull request #745 from PR0M3TH3AN/codex/implement-caching-for-admin-state
Cache admin lists in localStorage for offline fallback
This commit is contained in:
3 files changed
+220
-60
No files matched your search
+62
-40
@@ -8,7 +8,11 @@ import {
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
isLockdownMode,
|
||||
} from "./config.js";
|
||||
import { loadAdminState, persistAdminState } from "./adminListStore.js";
|
||||
import {
|
||||
loadAdminState,
|
||||
persistAdminState,
|
||||
readCachedAdminState,
|
||||
} from "./adminListStore.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
|
||||
function normalizeNpub(value) {
|
||||
@@ -54,6 +58,60 @@ class AccessControl {
|
||||
this.lastError = null;
|
||||
this._isRefreshing = false;
|
||||
this._refreshPromise = Promise.resolve();
|
||||
this._hydratedFromCache = false;
|
||||
|
||||
this._hydrateFromCache();
|
||||
}
|
||||
|
||||
_hydrateFromCache() {
|
||||
this._hydratedFromCache = false;
|
||||
const cachedState = readCachedAdminState();
|
||||
if (cachedState) {
|
||||
this._applyState(cachedState, { markLoaded: false });
|
||||
this._hydratedFromCache = true;
|
||||
}
|
||||
}
|
||||
|
||||
_applyState(state, options = {}) {
|
||||
const markLoaded = options.markLoaded !== false;
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : [];
|
||||
const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : [];
|
||||
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||
this.whitelist = new Set(normalizedWhitelist);
|
||||
|
||||
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
|
||||
const adminGuardSet = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...Array.from(this.editors),
|
||||
]);
|
||||
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (adminGuardSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blacklist = new Set(sanitizedBlacklist);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.lastError = null;
|
||||
|
||||
if (markLoaded) {
|
||||
this.hasLoaded = true;
|
||||
this._hydratedFromCache = false;
|
||||
}
|
||||
}
|
||||
|
||||
_performRefresh() {
|
||||
@@ -66,47 +124,10 @@ class AccessControl {
|
||||
const operation = (async () => {
|
||||
try {
|
||||
const state = await loadAdminState();
|
||||
const editors = Array.isArray(state?.editors) ? state.editors : [];
|
||||
const whitelist = Array.isArray(state?.whitelist)
|
||||
? state.whitelist
|
||||
: [];
|
||||
const blacklist = Array.isArray(state?.blacklist)
|
||||
? state.blacklist
|
||||
: [];
|
||||
|
||||
this.editors = new Set(
|
||||
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
|
||||
);
|
||||
const normalizedWhitelist = dedupeNpubs(whitelist);
|
||||
this.whitelist = new Set(normalizedWhitelist);
|
||||
|
||||
const blacklistDedupe = dedupeNpubs(blacklist);
|
||||
const whitelistSet = new Set(normalizedWhitelist.map(normalizeNpub));
|
||||
const adminGuardSet = new Set([
|
||||
normalizeNpub(ADMIN_SUPER_NPUB),
|
||||
...Array.from(this.editors),
|
||||
]);
|
||||
const sanitizedBlacklist = blacklistDedupe.filter((npub) => {
|
||||
const normalized = normalizeNpub(npub);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (whitelistSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (adminGuardSet.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.blacklist = new Set(sanitizedBlacklist);
|
||||
|
||||
this.whitelistEnabled = getWhitelistMode();
|
||||
this.hasLoaded = true;
|
||||
this.lastError = null;
|
||||
this._applyState(state);
|
||||
} catch (error) {
|
||||
this.lastError = error;
|
||||
if (!this.hasLoaded) {
|
||||
if (!this.hasLoaded && !this._hydratedFromCache) {
|
||||
this.editors.clear();
|
||||
this.whitelist.clear();
|
||||
this.blacklist.clear();
|
||||
@@ -417,6 +438,7 @@ class AccessControl {
|
||||
|
||||
export const accessControl = new AccessControl();
|
||||
export {
|
||||
AccessControl,
|
||||
ADMIN_WHITELIST_MODE_STORAGE_KEY,
|
||||
normalizeNpub,
|
||||
isValidNpub,
|
||||
|
||||
+102
-20
@@ -28,6 +28,9 @@ const LEGACY_STORAGE_KEYS = {
|
||||
blacklistLegacy: "bitvid_blacklist",
|
||||
};
|
||||
|
||||
const ADMIN_STATE_CACHE_VERSION = 1;
|
||||
const ADMIN_STATE_CACHE_KEY = `bitvid_admin_state_v${ADMIN_STATE_CACHE_VERSION}`;
|
||||
|
||||
function createError(code, message, cause) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
@@ -264,6 +267,54 @@ function loadLegacyAdminState() {
|
||||
return hasAnyEntries(sanitized) ? sanitized : null;
|
||||
}
|
||||
|
||||
export function readCachedAdminState() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(ADMIN_STATE_CACHE_KEY);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[adminListStore] Failed to read admin state cache:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return sanitizeAdminState(parsed || {});
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[adminListStore] Failed to parse admin state cache:",
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedAdminState(state) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sanitized = sanitizeAdminState(state || {});
|
||||
localStorage.setItem(ADMIN_STATE_CACHE_KEY, JSON.stringify(sanitized));
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[adminListStore] Failed to write admin state cache:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function clearLegacyStorageFor(listKey) {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
@@ -958,33 +1009,64 @@ export async function loadAdminState() {
|
||||
);
|
||||
}
|
||||
|
||||
const nostrState = await loadNostrState();
|
||||
let mergedState = null;
|
||||
let cachedState = null;
|
||||
|
||||
let mergedState = nostrState;
|
||||
try {
|
||||
const communityEntries = await loadCommunityBlacklistEntries();
|
||||
if (Array.isArray(communityEntries) && communityEntries.length) {
|
||||
const baseEditors = Array.isArray(nostrState?.editors)
|
||||
? nostrState.editors
|
||||
: [];
|
||||
const baseWhitelist = Array.isArray(nostrState?.whitelist)
|
||||
? nostrState.whitelist
|
||||
: [];
|
||||
const baseBlacklist = Array.isArray(nostrState?.blacklist)
|
||||
? nostrState.blacklist
|
||||
: [];
|
||||
const nostrState = await loadNostrState();
|
||||
|
||||
mergedState = sanitizeAdminState({
|
||||
editors: baseEditors,
|
||||
whitelist: baseWhitelist,
|
||||
blacklist: [...baseBlacklist, ...communityEntries],
|
||||
});
|
||||
mergedState = nostrState;
|
||||
try {
|
||||
const communityEntries = await loadCommunityBlacklistEntries();
|
||||
if (Array.isArray(communityEntries) && communityEntries.length) {
|
||||
const baseEditors = Array.isArray(nostrState?.editors)
|
||||
? nostrState.editors
|
||||
: [];
|
||||
const baseWhitelist = Array.isArray(nostrState?.whitelist)
|
||||
? nostrState.whitelist
|
||||
: [];
|
||||
const baseBlacklist = Array.isArray(nostrState?.blacklist)
|
||||
? nostrState.blacklist
|
||||
: [];
|
||||
|
||||
mergedState = sanitizeAdminState({
|
||||
editors: baseEditors,
|
||||
whitelist: baseWhitelist,
|
||||
blacklist: [...baseBlacklist, ...communityEntries],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[adminListStore] Failed to merge community blacklist entries:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[adminListStore] Failed to merge community blacklist entries:",
|
||||
"[adminListStore] Failed to load admin lists from relays:",
|
||||
error,
|
||||
);
|
||||
|
||||
cachedState = readCachedAdminState();
|
||||
if (cachedState) {
|
||||
return cachedState;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!cachedState) {
|
||||
cachedState = readCachedAdminState();
|
||||
}
|
||||
|
||||
if (mergedState) {
|
||||
if (hasAnyEntries(mergedState)) {
|
||||
writeCachedAdminState(mergedState);
|
||||
} else if (cachedState && hasAnyEntries(cachedState)) {
|
||||
return cachedState;
|
||||
} else {
|
||||
writeCachedAdminState(mergedState);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyEntries(mergedState)) {
|
||||
@@ -997,7 +1079,7 @@ export async function loadAdminState() {
|
||||
}
|
||||
}
|
||||
|
||||
return mergedState;
|
||||
return mergedState || sanitizeAdminState();
|
||||
}
|
||||
|
||||
export async function persistAdminState(actorNpub, updates) {
|
||||
|
||||
@@ -47,6 +47,7 @@ process.on("exit", () => {
|
||||
const {
|
||||
loadAdminState,
|
||||
persistAdminState,
|
||||
readCachedAdminState,
|
||||
__adminListStoreTestHooks,
|
||||
} = await import(
|
||||
"../js/adminListStore.js"
|
||||
@@ -61,6 +62,7 @@ const {
|
||||
const { ADMIN_LIST_IDENTIFIERS } = await import(
|
||||
"../js/nostrEventSchemas.js"
|
||||
);
|
||||
const { AccessControl } = await import("../js/accessControl.js");
|
||||
|
||||
const {
|
||||
extractNpubsFromEvent,
|
||||
@@ -106,6 +108,10 @@ mockNip19.npubEncode = undefined;
|
||||
|
||||
globalThis.window.NostrTools.nip19 = mockNip19;
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
const fallbackHex = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||
|
||||
assert.equal(
|
||||
@@ -255,6 +261,10 @@ nostrClient.pool = {
|
||||
const dTag = dValues.length ? dValues[0] : "";
|
||||
const authorKey = authors.length ? authors[0] : "*";
|
||||
|
||||
if (listFailureMode === "throw-all") {
|
||||
throw new Error("list failure for cache test");
|
||||
}
|
||||
|
||||
if (listFailureMode === "throw-authors" && authors.length) {
|
||||
throw new Error("list failure for authors");
|
||||
}
|
||||
@@ -385,6 +395,52 @@ assert.ok(
|
||||
"should exclude editor members from merged blacklist",
|
||||
);
|
||||
|
||||
const cachedSnapshot = readCachedAdminState();
|
||||
assert.deepEqual(
|
||||
cachedSnapshot,
|
||||
adminState,
|
||||
"should cache the merged admin state after successful load",
|
||||
);
|
||||
|
||||
setListFailureMode("throw-all");
|
||||
|
||||
const cachedFallback = await loadAdminState();
|
||||
assert.deepEqual(
|
||||
cachedFallback,
|
||||
adminState,
|
||||
"should return cached admin state when relay queries fail",
|
||||
);
|
||||
|
||||
const accessControlFromCache = new AccessControl();
|
||||
|
||||
assert.ok(
|
||||
accessControlFromCache.getEditors().includes(editorNpub),
|
||||
"should hydrate access control editors from cached state",
|
||||
);
|
||||
assert.deepEqual(
|
||||
accessControlFromCache.getWhitelist(),
|
||||
adminState.whitelist,
|
||||
"should hydrate access control whitelist from cached state",
|
||||
);
|
||||
assert.deepEqual(
|
||||
accessControlFromCache.getBlacklist(),
|
||||
adminState.blacklist,
|
||||
"should hydrate access control blacklist from cached state",
|
||||
);
|
||||
|
||||
await assert.doesNotReject(
|
||||
() => accessControlFromCache.refresh(),
|
||||
"should keep cached state available when refresh relies on cached data",
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
accessControlFromCache.getBlacklist(),
|
||||
adminState.blacklist,
|
||||
"should preserve cached blacklist entries after a failed refresh",
|
||||
);
|
||||
|
||||
setListFailureMode(null);
|
||||
|
||||
resetListRegistry();
|
||||
setListFailureMode("throw-authors");
|
||||
|
||||
|
||||
Reference in new issue
Block a user