mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Handle plaintext watch history chunks
This commit is contained in:
1 parent
f473fc69e3
commit
9416efdf1a
2 files changed
+320
-15
No files matched your search
+132
-15
@@ -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) =>
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user