Handle plaintext watch history chunks

This commit is contained in:
thePR0M3TH3AN committed 2025-10-01 19:20:11 -04:00
1 parent f473fc69e3
commit 9416efdf1a
2 files changed
+320 -15

No files matched your search

+132 -15
View File
@@ -2012,6 +2012,20 @@ class NostrClient {
totalChunks: 1,
};
const ciphertextRaw =
pointerEvent && typeof pointerEvent.content === "string"
? pointerEvent.content
: "";
const ciphertext = ciphertextRaw.trim();
if (!isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext)) {
return parseWatchHistoryContentWithFallback(
ciphertext,
fallbackItems,
fallbackPayload
);
}
const normalizedActor =
typeof actorPubkey === "string" && actorPubkey.trim()
? actorPubkey.trim().toLowerCase()
@@ -2020,11 +2034,6 @@ class NostrClient {
return fallbackPayload;
}
const ciphertext =
pointerEvent && typeof pointerEvent.content === "string"
? pointerEvent.content.trim()
: "";
if (!ciphertext) {
return fallbackPayload;
}
@@ -2042,11 +2051,11 @@ class NostrClient {
normalizedActor,
ciphertext
);
const parsed = parseWatchHistoryPayload(plaintext);
if (parsed.items.length || fallbackItems.length === 0) {
return parsed;
}
return { ...parsed, items: fallbackItems };
return parseWatchHistoryContentWithFallback(
typeof plaintext === "string" ? plaintext.trim() : "",
fallbackItems,
fallbackPayload
);
} catch (error) {
if (isDevMode) {
console.warn(
@@ -2078,11 +2087,11 @@ class NostrClient {
normalizedActor,
ciphertext
);
const parsed = parseWatchHistoryPayload(plaintext);
if (parsed.items.length || fallbackItems.length === 0) {
return parsed;
}
return { ...parsed, items: fallbackItems };
return parseWatchHistoryContentWithFallback(
typeof plaintext === "string" ? plaintext.trim() : "",
fallbackItems,
fallbackPayload
);
} catch (error) {
if (isDevMode) {
console.warn(
@@ -6021,6 +6030,114 @@ class NostrClient {
}
}
function looksLikeJsonStructure(content) {
if (typeof content !== "string") {
return false;
}
const trimmed = content.trim();
if (!trimmed) {
return false;
}
const first = trimmed[0];
return first === "{" || first === "[";
}
function mergeWatchHistoryItemsWithFallback(parsed, fallbackItems) {
if (!parsed || typeof parsed !== "object") {
return {
version: 0,
items: fallbackItems,
snapshot: "",
chunkIndex: 0,
totalChunks: 1,
};
}
if (Array.isArray(parsed.items) && parsed.items.length) {
return parsed;
}
if (!Array.isArray(fallbackItems) || fallbackItems.length === 0) {
return parsed;
}
return { ...parsed, items: fallbackItems };
}
function parseWatchHistoryContentWithFallback(
content,
fallbackItems,
fallbackPayload
) {
if (!looksLikeJsonStructure(content)) {
return fallbackPayload;
}
try {
JSON.parse(content);
} catch (error) {
return fallbackPayload;
}
const parsed = parseWatchHistoryPayload(content);
return mergeWatchHistoryItemsWithFallback(parsed, fallbackItems);
}
function isNip04EncryptedWatchHistoryEvent(pointerEvent, ciphertext) {
if (!pointerEvent || typeof pointerEvent !== "object") {
return false;
}
const tags = Array.isArray(pointerEvent.tags) ? pointerEvent.tags : [];
const normalizedCiphertext =
typeof ciphertext === "string" ? ciphertext.trim() : "";
if (!normalizedCiphertext) {
return false;
}
const hasEncryptionTag = tags.some((tag) => {
if (!Array.isArray(tag) || tag.length < 2) {
return false;
}
const label =
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
if (label !== "encrypted") {
return false;
}
const value =
typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
return value === "nip04" || value === "nip-04";
});
if (hasEncryptionTag) {
return !looksLikeJsonStructure(normalizedCiphertext);
}
if (looksLikeJsonStructure(normalizedCiphertext)) {
return false;
}
const ivIndex = normalizedCiphertext.indexOf("?iv=");
const baseSegment =
ivIndex >= 0
? normalizedCiphertext.slice(0, ivIndex)
: normalizedCiphertext;
const ivSegment =
ivIndex >= 0 ? normalizedCiphertext.slice(ivIndex + 4) : "";
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
if (!baseSegment || !base64Regex.test(baseSegment)) {
return false;
}
if (ivSegment && !base64Regex.test(ivSegment)) {
return false;
}
return true;
}
export const nostrClient = new NostrClient();
export const recordVideoView = (...args) =>
+188
View File
@@ -1066,6 +1066,194 @@ try {
Date.now = originalDateNow;
}
const plaintextPayload = {
version: 2,
snapshot: "plaintext-snapshot",
chunkIndex: 0,
totalChunks: 1,
items: [
{
type: "e",
value: "plaintext-pointer",
relay: "wss://relay.unit",
watchedAt: 1_700_000_123_000,
},
],
};
const plaintextChunkEvent = {
kind: WATCH_HISTORY_KIND,
pubkey: ACTOR,
created_at: 333,
tags: [
["d", `${WATCH_HISTORY_CHUNK_PREFIX}/plaintext/0`],
["snapshot", plaintextPayload.snapshot],
["chunk", String(plaintextPayload.chunkIndex), String(plaintextPayload.totalChunks)],
["e", plaintextPayload.items[0].value, plaintextPayload.items[0].relay],
],
content: JSON.stringify(plaintextPayload),
};
const originalWindowNostr = globalThis.window.nostr;
if (!globalThis.window.NostrTools) {
globalThis.window.NostrTools = {};
}
if (!globalThis.window.NostrTools.nip04) {
globalThis.window.NostrTools.nip04 = {};
}
const originalSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
try {
globalThis.window.nostr = {
nip04: {
decrypt: () => {
throw new Error("plaintext chunk should not trigger extension decrypt");
},
},
};
globalThis.window.NostrTools.nip04.decrypt = () => {
throw new Error("plaintext chunk should not trigger session decrypt");
};
const plaintextClient = createDecryptClient(ACTOR);
const plaintextResult = await plaintextClient.decryptWatchHistoryEvent(
plaintextChunkEvent,
ACTOR
);
assert.deepEqual(
plaintextResult.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay,
watchedAt: item.watchedAt,
})),
plaintextPayload.items,
"plaintext payload should be parsed without invoking nip04 helpers",
);
assert.equal(
plaintextResult.snapshot,
plaintextPayload.snapshot,
"plaintext payload should preserve snapshot metadata",
);
assert.equal(
plaintextResult.chunkIndex,
plaintextPayload.chunkIndex,
"plaintext payload should preserve chunk index",
);
assert.equal(
plaintextResult.totalChunks,
plaintextPayload.totalChunks,
"plaintext payload should preserve chunk count",
);
} finally {
globalThis.window.nostr = originalWindowNostr;
if (originalSessionDecrypt === undefined) {
delete globalThis.window.NostrTools.nip04.decrypt;
} else {
globalThis.window.NostrTools.nip04.decrypt = originalSessionDecrypt;
}
}
const encryptedPayload = {
version: 2,
snapshot: "encrypted-snapshot",
chunkIndex: 0,
totalChunks: 1,
items: [
{
type: "e",
value: "encrypted-pointer",
relay: "wss://relay.unit",
watchedAt: 1_700_000_456_000,
},
],
};
const encryptedCiphertext = "ZW5jcnlwdGVkQ29udGVudD9mb29iYXI=?iv=YWJjMTIz";
const encryptedChunkEvent = {
kind: WATCH_HISTORY_KIND,
pubkey: ACTOR,
created_at: 444,
tags: [
["d", `${WATCH_HISTORY_CHUNK_PREFIX}/encrypted/0`],
["encrypted", "nip04"],
["snapshot", encryptedPayload.snapshot],
["chunk", String(encryptedPayload.chunkIndex), String(encryptedPayload.totalChunks)],
["e", encryptedPayload.items[0].value, encryptedPayload.items[0].relay],
],
content: encryptedCiphertext,
};
const encryptedClient = createDecryptClient(ACTOR);
encryptedClient.pubkey = ACTOR;
const priorWindowNostr = globalThis.window.nostr;
const priorSessionDecrypt = globalThis.window.NostrTools.nip04.decrypt;
let extensionDecryptCalls = 0;
let sessionDecryptCalls = 0;
try {
globalThis.window.nostr = {
nip04: {
decrypt: async (pubkey, ciphertext) => {
extensionDecryptCalls += 1;
assert.equal(pubkey, ACTOR, "extension decrypt should target the actor");
assert.equal(
ciphertext,
encryptedCiphertext,
"extension decrypt should receive the chunk ciphertext",
);
return JSON.stringify(encryptedPayload);
},
},
};
globalThis.window.NostrTools.nip04.decrypt = async () => {
sessionDecryptCalls += 1;
return JSON.stringify(encryptedPayload);
};
const decryptedResult = await encryptedClient.decryptWatchHistoryEvent(
encryptedChunkEvent,
ACTOR
);
assert.equal(
extensionDecryptCalls,
1,
"encrypted chunks should invoke extension nip04 decrypt",
);
assert.equal(
sessionDecryptCalls,
0,
"successful extension decrypt should skip session fallback",
);
assert.deepEqual(
decryptedResult.items.map((item) => ({
type: item.type,
value: item.value,
relay: item.relay,
watchedAt: item.watchedAt,
})),
encryptedPayload.items,
"encrypted payload should be reconstructed from decrypted content",
);
assert.equal(
decryptedResult.snapshot,
encryptedPayload.snapshot,
"encrypted payload should preserve snapshot metadata",
);
} finally {
globalThis.window.nostr = priorWindowNostr;
if (priorSessionDecrypt === undefined) {
delete globalThis.window.NostrTools.nip04.decrypt;
} else {
globalThis.window.NostrTools.nip04.decrypt = priorSessionDecrypt;
}
}
const unsortedVideos = [
{ id: "alpha", watchHistory: { watchedAt: 1_700_000_000_500 } },
{ id: "bravo", watchHistory: { watchedAt: 1_700_000_010_000 } },