mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 09:22:24 +00:00
Merge pull request #707 from PR0M3TH3AN/codex/implement-batch-delete-event-mechanism
Add structured relay delete handling for video removals
This commit is contained in:
3 files changed
+439
-14
No files matched your search
+176
-5
@@ -38,6 +38,7 @@ import {
|
||||
publishEventToRelay,
|
||||
publishEventToRelays,
|
||||
assertAnyRelayAccepted,
|
||||
summarizePublishResults,
|
||||
} from "./nostrPublish.js";
|
||||
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
@@ -7131,7 +7132,11 @@ export class NostrClient {
|
||||
});
|
||||
}
|
||||
|
||||
return signedEvent;
|
||||
return {
|
||||
event: signedEvent,
|
||||
publishResults,
|
||||
summary: publishSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7226,6 +7231,9 @@ export class NostrClient {
|
||||
throw new Error("No existing events found for that root.");
|
||||
}
|
||||
|
||||
const revertSummaries = [];
|
||||
const revertEvents = [];
|
||||
|
||||
for (const vid of matchingEvents.values()) {
|
||||
const baseRoot =
|
||||
(typeof vid.videoRootId === "string" && vid.videoRootId) ||
|
||||
@@ -7251,7 +7259,7 @@ export class NostrClient {
|
||||
videoRootId: baseRoot,
|
||||
};
|
||||
|
||||
const signedRevert = await this.revertVideo(
|
||||
const revertResult = await this.revertVideo(
|
||||
{
|
||||
id: vid.id,
|
||||
pubkey: vid.pubkey,
|
||||
@@ -7261,6 +7269,26 @@ export class NostrClient {
|
||||
pubkey
|
||||
);
|
||||
|
||||
const revertEvent = revertResult?.event || null;
|
||||
const revertSummary =
|
||||
revertResult?.summary ||
|
||||
summarizePublishResults(revertResult?.publishResults || []);
|
||||
const revertPublishResults = Array.isArray(revertResult?.publishResults)
|
||||
? revertResult.publishResults
|
||||
: [];
|
||||
|
||||
revertSummaries.push({
|
||||
targetId: vid.id || "",
|
||||
event: revertEvent,
|
||||
publishResults: revertPublishResults,
|
||||
summary: revertSummary,
|
||||
});
|
||||
|
||||
if (revertEvent?.id) {
|
||||
revertEvents.push(revertEvent);
|
||||
this.rawEvents.set(revertEvent.id, revertEvent);
|
||||
}
|
||||
|
||||
const cached = this.allEvents.get(vid.id) || vid;
|
||||
cached.deleted = true;
|
||||
cached.url = "";
|
||||
@@ -7273,16 +7301,159 @@ export class NostrClient {
|
||||
const activeKey = getActiveKey(cached);
|
||||
if (activeKey) {
|
||||
this.activeMap.delete(activeKey);
|
||||
const revertCreatedAt = Number.isFinite(signedRevert?.created_at)
|
||||
? Math.floor(signedRevert.created_at)
|
||||
const revertCreatedAt = Number.isFinite(revertEvent?.created_at)
|
||||
? Math.floor(revertEvent.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
this.recordTombstone(activeKey, revertCreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
const eventIdSet = new Set();
|
||||
const addressPointerSet = new Set();
|
||||
|
||||
const collectIdentifiersFromEvent = (eventLike) => {
|
||||
if (!eventLike || typeof eventLike !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventId = typeof eventLike.id === "string" ? eventLike.id : "";
|
||||
if (eventId) {
|
||||
eventIdSet.add(eventId);
|
||||
}
|
||||
|
||||
const pointerSources = [];
|
||||
if (eventLike.kind && Array.isArray(eventLike.tags)) {
|
||||
pointerSources.push(eventLike);
|
||||
}
|
||||
if (eventId) {
|
||||
const raw = this.rawEvents.get(eventId);
|
||||
if (raw && raw !== eventLike) {
|
||||
pointerSources.push(raw);
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of pointerSources) {
|
||||
const pointer = eventToAddressPointer(source);
|
||||
if (pointer) {
|
||||
addressPointerSet.add(pointer);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
matchingEvents.forEach((event) => collectIdentifiersFromEvent(event));
|
||||
revertEvents.forEach((event) => collectIdentifiersFromEvent(event));
|
||||
if (targetVideo) {
|
||||
collectIdentifiersFromEvent(targetVideo);
|
||||
}
|
||||
|
||||
const identifierRecords = [];
|
||||
eventIdSet.forEach((value) => {
|
||||
identifierRecords.push({ type: "e", value });
|
||||
});
|
||||
addressPointerSet.forEach((value) => {
|
||||
identifierRecords.push({ type: "a", value });
|
||||
});
|
||||
|
||||
const deleteSummaries = [];
|
||||
if (identifierRecords.length) {
|
||||
const extension = window?.nostr;
|
||||
const extensionSigner =
|
||||
extension && typeof extension.signEvent === "function"
|
||||
? extension.signEvent.bind(extension)
|
||||
: null;
|
||||
|
||||
if (typeof extensionSigner !== "function") {
|
||||
const error = new Error(
|
||||
"A NIP-07 extension with signEvent support is required to delete videos.",
|
||||
);
|
||||
error.code = "nostr-extension-missing";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const permissionResult = await this.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
if (!permissionResult.ok) {
|
||||
userLogger.warn(
|
||||
"[nostr] Extension permissions denied while deleting videos.",
|
||||
permissionResult.error,
|
||||
);
|
||||
const error = new Error(
|
||||
"The NIP-07 extension must grant decrypt and sign permissions before deleting a video.",
|
||||
);
|
||||
error.code = "extension-permission-denied";
|
||||
error.cause = permissionResult.error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const chunkSize = 100;
|
||||
for (let index = 0; index < identifierRecords.length; index += chunkSize) {
|
||||
const chunk = identifierRecords.slice(index, index + chunkSize);
|
||||
const deleteTags = chunk.map((record) => [record.type, record.value]);
|
||||
|
||||
const deleteEvent = {
|
||||
kind: 5,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: deleteTags,
|
||||
content: inferredRoot
|
||||
? `Delete video root ${inferredRoot}`
|
||||
: "Delete published video events",
|
||||
};
|
||||
|
||||
const signedDelete = await extensionSigner(deleteEvent);
|
||||
const publishResults = await publishEventToRelays(
|
||||
this.pool,
|
||||
this.relays,
|
||||
signedDelete,
|
||||
);
|
||||
const publishSummary = summarizePublishResults(publishResults);
|
||||
|
||||
publishSummary.accepted.forEach(({ url }) =>
|
||||
devLogger.log(`Delete event published to ${url}`),
|
||||
);
|
||||
|
||||
if (publishSummary.failed.length) {
|
||||
publishSummary.failed.forEach(({ url, error: relayError }) => {
|
||||
const reason =
|
||||
relayError instanceof Error
|
||||
? relayError.message
|
||||
: relayError
|
||||
? String(relayError)
|
||||
: "publish failed";
|
||||
userLogger.warn(
|
||||
`[nostr] Delete event not accepted by ${url}: ${reason}`,
|
||||
relayError,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (signedDelete?.id) {
|
||||
this.rawEvents.set(signedDelete.id, signedDelete);
|
||||
}
|
||||
|
||||
deleteSummaries.push({
|
||||
event: signedDelete,
|
||||
publishResults,
|
||||
summary: publishSummary,
|
||||
identifiers: {
|
||||
events: chunk
|
||||
.filter((record) => record.type === "e")
|
||||
.map((record) => record.value),
|
||||
addresses: chunk
|
||||
.filter((record) => record.type === "a")
|
||||
.map((record) => record.value),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.saveLocalData();
|
||||
|
||||
return true;
|
||||
return {
|
||||
reverts: revertSummaries,
|
||||
deletes: deleteSummaries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -644,10 +644,64 @@ export class NostrService {
|
||||
confirm,
|
||||
video,
|
||||
});
|
||||
if (result) {
|
||||
this.emit("videos:deleted", { videoRootId, video, pubkey });
|
||||
|
||||
if (!result) {
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
|
||||
const revertFailures = [];
|
||||
const deleteFailures = [];
|
||||
|
||||
if (Array.isArray(result.reverts)) {
|
||||
for (const entry of result.reverts) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const failed = Array.isArray(entry?.summary?.failed)
|
||||
? entry.summary.failed.filter(Boolean)
|
||||
: [];
|
||||
if (failed.length) {
|
||||
revertFailures.push({
|
||||
targetId: entry.targetId || "",
|
||||
eventId: entry.event?.id || "",
|
||||
failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result.deletes)) {
|
||||
for (const entry of result.deletes) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const failed = Array.isArray(entry?.summary?.failed)
|
||||
? entry.summary.failed.filter(Boolean)
|
||||
: [];
|
||||
if (failed.length) {
|
||||
deleteFailures.push({
|
||||
eventId: entry.event?.id || "",
|
||||
identifiers: entry.identifiers || { events: [], addresses: [] },
|
||||
failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detail = {
|
||||
videoRootId,
|
||||
video,
|
||||
pubkey,
|
||||
result,
|
||||
revertFailures,
|
||||
deleteFailures,
|
||||
};
|
||||
|
||||
this.emit("videos:deleted", detail);
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async getOldEventById(eventId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
import { NostrClient } from "../js/nostr.js";
|
||||
import { NostrService } from "../js/services/nostrService.js";
|
||||
|
||||
await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
localStorage.clear();
|
||||
@@ -29,14 +30,67 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
|
||||
client.allEvents.set(baseVideo.id, { ...baseVideo });
|
||||
|
||||
const signedEvents = [];
|
||||
window.nostr = {
|
||||
signEvent: async (event) => {
|
||||
signedEvents.push(event);
|
||||
return { ...event, id: `signed-${signedEvents.length}` };
|
||||
},
|
||||
};
|
||||
|
||||
client.ensureExtensionPermissions = async () => ({ ok: true });
|
||||
client.relays = ["wss://relay.ok", "wss://relay.fail"];
|
||||
client.rawEvents.set(baseVideo.id, {
|
||||
id: baseVideo.id,
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
tags: baseVideo.tags,
|
||||
});
|
||||
|
||||
client.pool = {
|
||||
publish([url], event) {
|
||||
const shouldFail = event.kind === 5 && url.includes("fail");
|
||||
return {
|
||||
on(type, handler) {
|
||||
if (type === "ok") {
|
||||
if (!shouldFail) {
|
||||
handler();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (type === "failed") {
|
||||
if (shouldFail) {
|
||||
handler(new Error(`reject-${url}`));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const revertCalls = [];
|
||||
const revertEvents = [];
|
||||
client.revertVideo = async (event) => {
|
||||
revertCalls.push(event);
|
||||
const created_at = baseVideo.created_at + 500 + revertCalls.length;
|
||||
const revertEvent = { id: `revert-${revertCalls.length}`, created_at };
|
||||
const revertEvent = {
|
||||
id: `revert-${revertCalls.length}`,
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at,
|
||||
tags: baseVideo.tags,
|
||||
};
|
||||
revertEvents.push(revertEvent);
|
||||
return revertEvent;
|
||||
return {
|
||||
event: revertEvent,
|
||||
publishResults: client.relays.map((url) => ({ url, success: true })),
|
||||
summary: {
|
||||
accepted: client.relays.map((url) => ({ url, success: true })),
|
||||
failed: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const result = await client.deleteAllVersions(videoRootId, pubkey, {
|
||||
@@ -44,9 +98,66 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
video: baseVideo,
|
||||
});
|
||||
|
||||
assert.equal(result, true, "deleteAllVersions should resolve true on success");
|
||||
assert.equal(
|
||||
Array.isArray(result.deletes),
|
||||
true,
|
||||
"deleteAllVersions should return delete summaries",
|
||||
);
|
||||
assert.equal(revertCalls.length, 1, "deleteAllVersions should call revertVideo once");
|
||||
|
||||
const deleteSummary = result.deletes[0];
|
||||
assert.equal(
|
||||
deleteSummary.summary.failed.length,
|
||||
1,
|
||||
"deleteAllVersions should surface relay failures for delete events",
|
||||
);
|
||||
assert.equal(
|
||||
deleteSummary.summary.failed[0].url,
|
||||
"wss://relay.fail",
|
||||
"delete summary should identify the failing relay",
|
||||
);
|
||||
assert.equal(
|
||||
deleteSummary.identifiers.events.includes(baseVideo.id),
|
||||
true,
|
||||
"delete summary should include the original event id",
|
||||
);
|
||||
assert.equal(
|
||||
deleteSummary.identifiers.events.includes(revertEvents[0].id),
|
||||
true,
|
||||
"delete summary should include the revert event id",
|
||||
);
|
||||
assert.equal(
|
||||
deleteSummary.identifiers.addresses.includes(
|
||||
`30078:${pubkey}:${videoRootId}`,
|
||||
),
|
||||
true,
|
||||
"delete summary should include the address pointer for the root",
|
||||
);
|
||||
|
||||
const deleteRequest = signedEvents.find((event) => event.kind === 5);
|
||||
assert(deleteRequest, "deleteAllVersions should sign a kind 5 delete event");
|
||||
const eTags = deleteRequest.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "e")
|
||||
.map(([, value]) => value);
|
||||
assert.equal(
|
||||
eTags.includes(baseVideo.id),
|
||||
true,
|
||||
"delete event should include original event id as an e-tag",
|
||||
);
|
||||
assert.equal(
|
||||
eTags.includes(revertEvents[0].id),
|
||||
true,
|
||||
"delete event should include revert event id as an e-tag",
|
||||
);
|
||||
const aTags = deleteRequest.tags
|
||||
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
|
||||
.map(([, value]) => value);
|
||||
assert.equal(
|
||||
aTags.includes(`30078:${pubkey}:${videoRootId}`),
|
||||
true,
|
||||
"delete event should include the address pointer as an a-tag",
|
||||
);
|
||||
|
||||
const parsedPayload = JSON.parse(revertCalls[0].content);
|
||||
assert.equal(parsedPayload.deleted, true, "revert payload should mark deleted true");
|
||||
assert.equal(parsedPayload.videoRootId, videoRootId);
|
||||
@@ -89,7 +200,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
const deleteEvent = {
|
||||
id: "delete-event-1",
|
||||
pubkey,
|
||||
created_at: baseVideo.created_at + 100,
|
||||
created_at: revertEvents[0].created_at + 100,
|
||||
content: revertCalls[0].content,
|
||||
tags: baseVideo.tags,
|
||||
};
|
||||
@@ -117,6 +228,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
|
||||
deleteEvent.created_at,
|
||||
"subscription delete event should update tombstone",
|
||||
);
|
||||
delete window.nostr;
|
||||
})();
|
||||
|
||||
await (async function testTombstonePersistenceAcrossSaveRestore() {
|
||||
@@ -203,8 +315,8 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() {
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
deleted: true,
|
||||
title: "",
|
||||
url: "",
|
||||
title: "Deleted",
|
||||
url: "https://example.com/video.mp4",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "",
|
||||
@@ -269,4 +381,92 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() {
|
||||
);
|
||||
})();
|
||||
|
||||
await (async function testNostrServiceBubblesDeleteFailures() {
|
||||
const service = new NostrService();
|
||||
const pubkey = "PUBKEY123";
|
||||
const videoRootId = "root-service";
|
||||
const sampleVideo = { id: "evt-service", pubkey, videoRootId };
|
||||
|
||||
const revertFailure = {
|
||||
url: "wss://relay.fail",
|
||||
error: new Error("revert failed"),
|
||||
success: false,
|
||||
};
|
||||
const deleteFailure = {
|
||||
url: "wss://relay.fail",
|
||||
error: new Error("delete failed"),
|
||||
success: false,
|
||||
};
|
||||
|
||||
const summary = {
|
||||
reverts: [
|
||||
{
|
||||
targetId: sampleVideo.id,
|
||||
event: { id: "revert-event" },
|
||||
publishResults: [
|
||||
{ url: "wss://relay.ok", success: true },
|
||||
revertFailure,
|
||||
],
|
||||
summary: {
|
||||
accepted: [{ url: "wss://relay.ok", success: true }],
|
||||
failed: [revertFailure],
|
||||
},
|
||||
},
|
||||
],
|
||||
deletes: [
|
||||
{
|
||||
event: { id: "delete-event" },
|
||||
identifiers: {
|
||||
events: [sampleVideo.id],
|
||||
addresses: ["30078:pubkey:root-service"],
|
||||
},
|
||||
publishResults: [
|
||||
{ url: "wss://relay.ok", success: true },
|
||||
deleteFailure,
|
||||
],
|
||||
summary: {
|
||||
accepted: [{ url: "wss://relay.ok", success: true }],
|
||||
failed: [deleteFailure],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
service.nostrClient = {
|
||||
deleteAllVersions: async () => summary,
|
||||
};
|
||||
|
||||
const emitted = [];
|
||||
service.on("videos:deleted", (detail) => emitted.push(detail));
|
||||
|
||||
const detail = await service.handleFullDeleteVideo({
|
||||
videoRootId,
|
||||
video: sampleVideo,
|
||||
pubkey,
|
||||
confirm: false,
|
||||
});
|
||||
|
||||
assert.equal(emitted.length, 1, "service should emit videos:deleted event");
|
||||
assert.equal(
|
||||
emitted[0].result,
|
||||
summary,
|
||||
"emitted detail should include the delete summary",
|
||||
);
|
||||
assert.equal(
|
||||
detail.deleteFailures.length,
|
||||
1,
|
||||
"service should surface delete relay failures",
|
||||
);
|
||||
assert.equal(
|
||||
detail.revertFailures.length,
|
||||
1,
|
||||
"service should surface revert relay failures",
|
||||
);
|
||||
assert.equal(
|
||||
detail.deleteFailures[0].failed[0].url,
|
||||
"wss://relay.fail",
|
||||
"delete failures should include the failing relay url",
|
||||
);
|
||||
})();
|
||||
|
||||
console.log("nostr delete flow tests passed");
|
||||
Reference in new issue
Block a user