mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #386 from PR0M3TH3AN/codex/update-normalizeactorkey-and-watch-history
Normalize watch history actor keys for hex-based decrypt
This commit is contained in:
3 files changed
+316
-95
No files matched your search
@@ -1113,6 +1113,8 @@ class Application {
|
||||
// Grab the "Subscriptions" link by its id in the sidebar
|
||||
this.subscriptionsLink = document.getElementById("subscriptionsLink");
|
||||
|
||||
this.syncAuthUiState();
|
||||
|
||||
const savedPubKey = this.activeProfilePubkey;
|
||||
if (savedPubKey) {
|
||||
// Auto-login if a pubkey was saved
|
||||
@@ -1121,11 +1123,6 @@ class Application {
|
||||
} catch (error) {
|
||||
console.error("Auto-login failed:", error);
|
||||
}
|
||||
|
||||
// If the user was already logged in, show the Subscriptions link
|
||||
if (this.subscriptionsLink) {
|
||||
this.subscriptionsLink.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Setup general event listeners
|
||||
@@ -2772,6 +2769,82 @@ class Application {
|
||||
return this.nwcSettingsService.clearStoredNwcSettings(pubkey, options);
|
||||
}
|
||||
|
||||
applyAuthenticatedUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.add("hidden");
|
||||
this.loginButton.setAttribute("hidden", "");
|
||||
this.loginButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.remove("hidden");
|
||||
this.uploadButton.removeAttribute("hidden");
|
||||
this.uploadButton.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.remove("hidden");
|
||||
this.profileButton.removeAttribute("hidden");
|
||||
this.profileButton.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
if (this.subscriptionsLink) {
|
||||
this.subscriptionsLink.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
applyLoggedOutUiState() {
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.remove("hidden");
|
||||
this.loginButton.removeAttribute("hidden");
|
||||
this.loginButton.style.display = "";
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userPubKey) {
|
||||
this.userPubKey.textContent = "";
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.add("hidden");
|
||||
this.uploadButton.setAttribute("hidden", "");
|
||||
this.uploadButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.add("hidden");
|
||||
this.profileButton.setAttribute("hidden", "");
|
||||
this.profileButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.subscriptionsLink) {
|
||||
this.subscriptionsLink.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
syncAuthUiState() {
|
||||
if (this.isUserLoggedIn()) {
|
||||
this.applyAuthenticatedUiState();
|
||||
} else {
|
||||
this.applyLoggedOutUiState();
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthLogin(detail = {}) {
|
||||
if (detail && typeof detail === "object") {
|
||||
try {
|
||||
@@ -2804,35 +2877,7 @@ class Application {
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.add("hidden");
|
||||
this.loginButton.setAttribute("hidden", "");
|
||||
this.loginButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.remove("hidden");
|
||||
this.uploadButton.removeAttribute("hidden");
|
||||
this.uploadButton.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.remove("hidden");
|
||||
this.profileButton.removeAttribute("hidden");
|
||||
this.profileButton.style.display = "inline-flex";
|
||||
}
|
||||
|
||||
if (this.subscriptionsLink) {
|
||||
this.subscriptionsLink.classList.remove("hidden");
|
||||
}
|
||||
this.applyAuthenticatedUiState();
|
||||
|
||||
const activePubkey = detail?.pubkey || this.pubkey;
|
||||
if (activePubkey && detail?.postLogin?.profile) {
|
||||
@@ -2871,39 +2916,7 @@ class Application {
|
||||
this.renderSavedProfiles();
|
||||
}
|
||||
|
||||
if (this.loginButton) {
|
||||
this.loginButton.classList.remove("hidden");
|
||||
this.loginButton.removeAttribute("hidden");
|
||||
this.loginButton.style.display = "";
|
||||
}
|
||||
|
||||
if (this.logoutButton) {
|
||||
this.logoutButton.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userStatus) {
|
||||
this.userStatus.classList.add("hidden");
|
||||
}
|
||||
|
||||
if (this.userPubKey) {
|
||||
this.userPubKey.textContent = "";
|
||||
}
|
||||
|
||||
if (this.uploadButton) {
|
||||
this.uploadButton.classList.add("hidden");
|
||||
this.uploadButton.setAttribute("hidden", "");
|
||||
this.uploadButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.profileButton) {
|
||||
this.profileButton.classList.add("hidden");
|
||||
this.profileButton.setAttribute("hidden", "");
|
||||
this.profileButton.style.display = "none";
|
||||
}
|
||||
|
||||
if (this.subscriptionsLink) {
|
||||
this.subscriptionsLink.classList.add("hidden");
|
||||
}
|
||||
this.applyLoggedOutUiState();
|
||||
|
||||
try {
|
||||
await this.loadVideos(true);
|
||||
|
||||
+63
-28
@@ -2317,8 +2317,18 @@ function normalizeActorKey(actor) {
|
||||
if (typeof actor !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const trimmed = actor.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const decodedHex = decodeNpubToHex(trimmed);
|
||||
if (decodedHex) {
|
||||
return decodedHex.toLowerCase();
|
||||
}
|
||||
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function canonicalizeWatchHistoryItems(rawItems, maxItems = WATCH_HISTORY_MAX_ITEMS) {
|
||||
@@ -4471,6 +4481,8 @@ export class NostrClient {
|
||||
return { pointerEvent: null, items: [], snapshotId: "" };
|
||||
}
|
||||
|
||||
const actorKeyIsHex = /^[0-9a-f]{64}$/.test(actorKey);
|
||||
|
||||
console.info("[nostr] Fetching watch history from relays.", {
|
||||
actor: resolvedActor,
|
||||
forceRefresh: options.forceRefresh === true,
|
||||
@@ -4482,6 +4494,50 @@ export class NostrClient {
|
||||
const now = Date.now();
|
||||
const ttl = this.getWatchHistoryCacheTtlMs();
|
||||
|
||||
const loadFromStorage = async () => {
|
||||
const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey];
|
||||
const items = Array.isArray(storageEntry?.items)
|
||||
? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS)
|
||||
: [];
|
||||
const fingerprint = typeof storageEntry?.fingerprint === "string"
|
||||
? storageEntry.fingerprint
|
||||
: await this.getWatchHistoryFingerprint(resolvedActor, items);
|
||||
const entry = {
|
||||
actor: resolvedActor,
|
||||
items,
|
||||
snapshotId: typeof storageEntry?.snapshotId === "string"
|
||||
? storageEntry.snapshotId
|
||||
: "",
|
||||
pointerEvent: null,
|
||||
chunkEvents: [],
|
||||
savedAt: now,
|
||||
fingerprint,
|
||||
metadata: sanitizeWatchHistoryMetadata(storageEntry?.metadata),
|
||||
};
|
||||
this.watchHistoryCache.set(actorKey, entry);
|
||||
this.persistWatchHistoryEntry(actorKey, entry);
|
||||
return { pointerEvent: null, items, snapshotId: entry.snapshotId };
|
||||
};
|
||||
|
||||
if (!actorKeyIsHex) {
|
||||
console.warn(
|
||||
`[nostr] Cannot normalize watch history actor key to hex. Aborting relay fetch for ${resolvedActor}.`,
|
||||
);
|
||||
if (
|
||||
!options.forceRefresh &&
|
||||
existingEntry &&
|
||||
Number.isFinite(existingEntry.savedAt) &&
|
||||
now - existingEntry.savedAt < ttl
|
||||
) {
|
||||
return {
|
||||
pointerEvent: existingEntry.pointerEvent || null,
|
||||
items: existingEntry.items || [],
|
||||
snapshotId: existingEntry.snapshotId || "",
|
||||
};
|
||||
}
|
||||
return await loadFromStorage();
|
||||
}
|
||||
|
||||
if (
|
||||
!options.forceRefresh &&
|
||||
existingEntry &&
|
||||
@@ -4534,7 +4590,7 @@ export class NostrClient {
|
||||
const filters = [
|
||||
{
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
authors: [resolvedActor],
|
||||
authors: [actorKey],
|
||||
"#d": identifiers,
|
||||
limit,
|
||||
},
|
||||
@@ -4574,28 +4630,7 @@ export class NostrClient {
|
||||
actor: resolvedActor,
|
||||
}
|
||||
);
|
||||
const storageEntry = this.getWatchHistoryStorage().actors?.[actorKey];
|
||||
const items = Array.isArray(storageEntry?.items)
|
||||
? canonicalizeWatchHistoryItems(storageEntry.items, WATCH_HISTORY_MAX_ITEMS)
|
||||
: [];
|
||||
const fingerprint = typeof storageEntry?.fingerprint === "string"
|
||||
? storageEntry.fingerprint
|
||||
: await this.getWatchHistoryFingerprint(resolvedActor, items);
|
||||
const entry = {
|
||||
actor: resolvedActor,
|
||||
items,
|
||||
snapshotId: typeof storageEntry?.snapshotId === "string"
|
||||
? storageEntry.snapshotId
|
||||
: "",
|
||||
pointerEvent: null,
|
||||
chunkEvents: [],
|
||||
savedAt: now,
|
||||
fingerprint,
|
||||
metadata: sanitizeWatchHistoryMetadata(storageEntry?.metadata),
|
||||
};
|
||||
this.watchHistoryCache.set(actorKey, entry);
|
||||
this.persistWatchHistoryEntry(actorKey, entry);
|
||||
return { pointerEvent: null, items, snapshotId: entry.snapshotId };
|
||||
return await loadFromStorage();
|
||||
}
|
||||
|
||||
const fallbackItems = extractPointerItemsFromEvent(pointerEvent);
|
||||
@@ -4647,14 +4682,14 @@ export class NostrClient {
|
||||
if (chunkIdentifiers.length) {
|
||||
chunkFilters.push({
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
authors: [resolvedActor],
|
||||
authors: [actorKey],
|
||||
"#d": chunkIdentifiers,
|
||||
limit: Math.max(chunkIdentifiers.length * 2, limit),
|
||||
});
|
||||
} else if (snapshotId) {
|
||||
chunkFilters.push({
|
||||
kinds: [WATCH_HISTORY_KIND],
|
||||
authors: [resolvedActor],
|
||||
authors: [actorKey],
|
||||
"#snapshot": [snapshotId],
|
||||
limit,
|
||||
});
|
||||
@@ -4725,7 +4760,7 @@ export class NostrClient {
|
||||
normalizeActorKey(this.pubkey) === actorKey;
|
||||
if (extensionDecrypt) {
|
||||
await this.ensureExtensionPermissions(DEFAULT_NIP07_PERMISSION_METHODS);
|
||||
return extension.nip04.decrypt(resolvedActor, ciphertext);
|
||||
return extension.nip04.decrypt(actorKey, ciphertext);
|
||||
}
|
||||
if (!this.sessionActor || this.sessionActor.pubkey !== actorKey) {
|
||||
await this.ensureSessionActor();
|
||||
@@ -4739,7 +4774,7 @@ export class NostrClient {
|
||||
}
|
||||
return tools.nip04.decrypt(
|
||||
this.sessionActor.privateKey,
|
||||
resolvedActor,
|
||||
actorKey,
|
||||
ciphertext,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ const {
|
||||
WATCH_HISTORY_CACHE_TTL_MS,
|
||||
WATCH_HISTORY_BATCH_RESOLVE,
|
||||
WATCH_HISTORY_BATCH_PAGE_SIZE,
|
||||
WATCH_HISTORY_LIST_IDENTIFIER,
|
||||
WATCH_HISTORY_KIND,
|
||||
} =
|
||||
await import("../js/config.js");
|
||||
const {
|
||||
@@ -266,6 +268,7 @@ function installExtensionCrypto({ actor }) {
|
||||
let extensionDecrypts = 0;
|
||||
let fallbackEncrypts = 0;
|
||||
const enableCalls = [];
|
||||
const decryptCalls = [];
|
||||
window.nostr = {
|
||||
enable: async (options) => {
|
||||
enableCalls.push(options || null);
|
||||
@@ -284,6 +287,7 @@ function installExtensionCrypto({ actor }) {
|
||||
},
|
||||
decrypt: async (target, ciphertext) => {
|
||||
extensionDecrypts += 1;
|
||||
decryptCalls.push({ target, ciphertext });
|
||||
const prefix = `extension:${target}:`;
|
||||
if (!ciphertext.startsWith(prefix)) {
|
||||
throw new Error("invalid-extension-ciphertext");
|
||||
@@ -320,6 +324,7 @@ function installExtensionCrypto({ actor }) {
|
||||
getExtensionDecrypts: () => extensionDecrypts,
|
||||
getFallbackEncrypts: () => fallbackEncrypts,
|
||||
getEnableCalls: () => [...enableCalls],
|
||||
getDecryptCalls: () => decryptCalls.map((entry) => ({ ...entry })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -343,6 +348,173 @@ function maxCreatedAt(events = []) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function testFetchWatchHistoryExtensionDecryptsHexAndNpub() {
|
||||
const actorHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const actorNpub = "npub-test-extension-decrypt";
|
||||
const snapshotId = "extension-fetch";
|
||||
const chunkIdentifier = "chunk-extension";
|
||||
|
||||
const fallbackTag = ["e", "fallback-pointer"];
|
||||
const fallbackItem = { type: "e", value: "fallback-pointer", watchedAt: 1_700_800_000 };
|
||||
const chunkItem = { type: "e", value: "chunk-pointer", watchedAt: 1_700_800_100 };
|
||||
|
||||
const chunkPayload = JSON.stringify({
|
||||
version: 1,
|
||||
items: [chunkItem],
|
||||
snapshot: snapshotId,
|
||||
chunkIndex: 0,
|
||||
totalChunks: 1,
|
||||
});
|
||||
const chunkCiphertext = `extension:${actorHex}:${Buffer.from(chunkPayload, "utf8").toString("base64")}`;
|
||||
|
||||
const pointerEvent = {
|
||||
id: "pointer-extension",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
pubkey: actorHex,
|
||||
created_at: 1_700_800_000,
|
||||
content: JSON.stringify({
|
||||
version: 1,
|
||||
items: [fallbackItem],
|
||||
snapshot: snapshotId,
|
||||
chunkIndex: 0,
|
||||
totalChunks: 1,
|
||||
}),
|
||||
tags: [
|
||||
["d", WATCH_HISTORY_LIST_IDENTIFIER],
|
||||
["snapshot", snapshotId],
|
||||
["encrypted", "nip04"],
|
||||
["a", `${WATCH_HISTORY_KIND}:${actorHex}:${chunkIdentifier}`],
|
||||
fallbackTag,
|
||||
],
|
||||
};
|
||||
|
||||
const chunkEvent = {
|
||||
id: "chunk-extension",
|
||||
kind: WATCH_HISTORY_KIND,
|
||||
pubkey: actorHex,
|
||||
created_at: 1_700_800_060,
|
||||
content: chunkCiphertext,
|
||||
tags: [
|
||||
["d", chunkIdentifier],
|
||||
["snapshot", snapshotId],
|
||||
["encrypted", "nip04"],
|
||||
],
|
||||
};
|
||||
|
||||
const previousNostrTools = window.NostrTools || {};
|
||||
window.NostrTools = {
|
||||
...previousNostrTools,
|
||||
nip19: {
|
||||
...(previousNostrTools.nip19 || {}),
|
||||
decode: (value) => {
|
||||
if (value === actorNpub) {
|
||||
return { type: "npub", data: actorHex };
|
||||
}
|
||||
if (
|
||||
previousNostrTools.nip19 &&
|
||||
typeof previousNostrTools.nip19.decode === "function"
|
||||
) {
|
||||
return previousNostrTools.nip19.decode(value);
|
||||
}
|
||||
throw new Error("unsupported-npub");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const originalEnsure = nostrClient.ensureSessionActor;
|
||||
const originalSession = nostrClient.sessionActor;
|
||||
const originalPub = nostrClient.pubkey;
|
||||
const originalCache = nostrClient.watchHistoryCache;
|
||||
const originalStorage = nostrClient.watchHistoryStorage;
|
||||
|
||||
nostrClient.watchHistoryCache = new Map();
|
||||
nostrClient.watchHistoryStorage = { version: 2, actors: {} };
|
||||
|
||||
const publishEvents = async () => {
|
||||
poolHarness.reset();
|
||||
poolHarness.setResolver(() => ({ ok: true }));
|
||||
poolHarness.publish(["wss://relay.test"], pointerEvent);
|
||||
poolHarness.publish(["wss://relay.test"], chunkEvent);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
|
||||
const runVariant = async (label, actorInput, pubkeyInput) => {
|
||||
await publishEvents();
|
||||
|
||||
const sessionCrypto = installSessionCrypto({ privateKey: `session-${label}` });
|
||||
const extensionCrypto = installExtensionCrypto({ actor: actorHex });
|
||||
|
||||
const previousEnsure = nostrClient.ensureSessionActor;
|
||||
const previousSession = nostrClient.sessionActor;
|
||||
const previousPub = nostrClient.pubkey;
|
||||
const previousStorage = nostrClient.watchHistoryStorage;
|
||||
|
||||
try {
|
||||
nostrClient.pubkey = pubkeyInput;
|
||||
nostrClient.sessionActor = null;
|
||||
nostrClient.ensureSessionActor = async () => {
|
||||
throw new Error(`session fallback should not run for ${label}`);
|
||||
};
|
||||
nostrClient.watchHistoryCache.clear();
|
||||
nostrClient.watchHistoryStorage = { version: 2, actors: {} };
|
||||
|
||||
const result = await nostrClient.fetchWatchHistory(actorInput, {
|
||||
forceRefresh: true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
extensionCrypto.getExtensionDecrypts(),
|
||||
1,
|
||||
`${label} actor should use extension decrypt`,
|
||||
);
|
||||
assert.equal(
|
||||
sessionCrypto.getDecryptCalls(),
|
||||
0,
|
||||
`${label} actor should not trigger session decrypt fallback`,
|
||||
);
|
||||
const decryptCalls = extensionCrypto.getDecryptCalls();
|
||||
assert.equal(
|
||||
decryptCalls.length,
|
||||
1,
|
||||
`${label} actor should invoke extension decrypt exactly once`,
|
||||
);
|
||||
assert.equal(
|
||||
decryptCalls[0]?.target,
|
||||
actorHex,
|
||||
`${label} actor should normalize npub inputs to hex for decrypt`,
|
||||
);
|
||||
|
||||
assert.equal(result.items.length, 1, `${label} actor should return chunk item`);
|
||||
assert.equal(
|
||||
result.items[0]?.value,
|
||||
chunkItem.value,
|
||||
`${label} actor should surface decrypted pointer value`,
|
||||
);
|
||||
} finally {
|
||||
extensionCrypto.restore();
|
||||
sessionCrypto.restore();
|
||||
nostrClient.ensureSessionActor = previousEnsure;
|
||||
nostrClient.sessionActor = previousSession;
|
||||
nostrClient.pubkey = previousPub;
|
||||
nostrClient.watchHistoryStorage = previousStorage;
|
||||
poolHarness.reset();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runVariant("hex", actorHex, actorHex);
|
||||
await runVariant("npub", actorNpub, actorNpub);
|
||||
} finally {
|
||||
window.NostrTools = previousNostrTools;
|
||||
nostrClient.ensureSessionActor = originalEnsure;
|
||||
nostrClient.sessionActor = originalSession;
|
||||
nostrClient.pubkey = originalPub;
|
||||
nostrClient.watchHistoryCache = originalCache;
|
||||
nostrClient.watchHistoryStorage = originalStorage;
|
||||
poolHarness.reset();
|
||||
}
|
||||
}
|
||||
|
||||
async function testPublishSnapshotCanonicalizationAndChunking() {
|
||||
poolHarness.reset();
|
||||
poolHarness.setResolver(() => ({ ok: true }));
|
||||
@@ -1392,6 +1564,7 @@ async function testWatchHistoryAppLoginFallback() {
|
||||
await testPublishSnapshotCanonicalizationAndChunking();
|
||||
await testPublishSnapshotUsesExtensionCrypto();
|
||||
await testEnsureExtensionPermissionCaching();
|
||||
await testFetchWatchHistoryExtensionDecryptsHexAndNpub();
|
||||
await testPublishSnapshotFailureRetry();
|
||||
await testWatchHistoryPartialRelayRetry();
|
||||
await testResolveWatchHistoryBatchingWindow();
|
||||
|
||||
Reference in new issue
Block a user