Merge pull request #1016 from PR0M3TH3AN/codex/extend-mirrorvideoevent-and-add-validation

Add file hash support to NIP-94 mirrors
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-30 12:42:35 -04:00
commit 513c24bcfe
3 files changed
+215 -54

No files matched your search

+165 -30
View File
@@ -3,6 +3,7 @@
import { isDevMode } from "../config.js";
import { FEATURE_PUBLISH_NIP71 } from "../constants.js";
import { accessControl } from "../accessControl.js";
import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js";
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
import {
buildNip71MetadataTags,
@@ -38,7 +39,6 @@ import {
} from "./watchHistory.js";
import {
buildVideoPostEvent,
buildVideoMirrorEvent,
buildRepostEvent,
buildWatchHistoryIndexEvent,
buildWatchHistoryChunkEvent,
@@ -413,6 +413,85 @@ function extractVideoPublishPayload(rawPayload) {
return { videoData: normalizedVideoData, nip71Metadata };
}
function normalizeHexHash(candidate) {
if (candidate === undefined || candidate === null) {
return "";
}
const stringValue =
typeof candidate === "string" ? candidate : String(candidate);
const trimmed = stringValue.trim().toLowerCase();
if (!trimmed) {
return "";
}
return HEX64_REGEX.test(trimmed) ? trimmed : "";
}
const BlobConstructor = typeof Blob !== "undefined" ? Blob : null;
let sharedTextEncoder = null;
function getSharedTextEncoder() {
if (!sharedTextEncoder && typeof TextEncoder !== "undefined") {
sharedTextEncoder = new TextEncoder();
}
return sharedTextEncoder;
}
async function valueToUint8Array(value) {
if (!value) {
return null;
}
try {
if (
BlobConstructor &&
value instanceof BlobConstructor &&
typeof value.arrayBuffer === "function"
) {
const buffer = await value.arrayBuffer();
return new Uint8Array(buffer);
}
} catch (error) {
devLogger.warn("[nostr] Failed to read Blob while computing hash:", error);
return null;
}
if (typeof ArrayBuffer !== "undefined") {
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
if (ArrayBuffer.isView?.(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
}
if (typeof Buffer !== "undefined" && Buffer.isBuffer?.(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (typeof value === "string") {
const encoder = getSharedTextEncoder();
return encoder ? encoder.encode(value) : null;
}
return null;
}
async function computeSha256HexFromValue(value) {
const data = await valueToUint8Array(value);
if (!data) {
return "";
}
try {
const digest = sha256(data);
const hex = typeof digest === "string" ? digest : bytesToHex(digest);
return hex ? hex.toLowerCase() : "";
} catch (error) {
devLogger.warn("[nostr] Failed to compute SHA-256 for mirror payload:", error);
return "";
}
}
function eventToAddressPointer(event) {
if (!event || typeof event !== "object") {
return "";
@@ -4052,49 +4131,105 @@ export class NostrClient {
"application/octet-stream";
const mimeType = mimeTypeSource.toLowerCase();
const mirrorTags = [
["url", finalUrl],
["m", mimeType],
const fileHashCandidates = [
videoData.fileSha256,
videoData.uploadedFileSha256,
videoPayload?.legacyFormData?.fileSha256,
videoPayload?.fileSha256,
];
if (finalThumbnail) {
mirrorTags.push(["thumb", finalThumbnail]);
let fileSha256 = "";
for (const candidate of fileHashCandidates) {
const normalized = normalizeHexHash(candidate);
if (normalized) {
fileSha256 = normalized;
break;
}
}
const altText = finalDescription || finalTitle || "";
if (altText) {
mirrorTags.push(["alt", altText]);
const originalHashCandidates = [
videoData.originalFileSha256,
videoPayload?.legacyFormData?.originalFileSha256,
videoPayload?.originalFileSha256,
];
let originalFileSha256 = "";
for (const candidate of originalHashCandidates) {
const normalized = normalizeHexHash(candidate);
if (normalized) {
originalFileSha256 = normalized;
break;
}
}
if (!contentObject.isPrivate && finalMagnet) {
mirrorTags.push(["magnet", finalMagnet]);
const uploadedFile =
videoData?.uploadedFile ||
videoData?.file ||
videoPayload?.legacyFormData?.uploadedFile ||
videoPayload?.legacyFormData?.file ||
videoPayload?.uploadedFile ||
videoPayload?.file ||
null;
const originalFile =
videoData?.originalFile ||
videoPayload?.legacyFormData?.originalFile ||
videoPayload?.originalFile ||
uploadedFile;
if (!fileSha256 && uploadedFile) {
fileSha256 = await computeSha256HexFromValue(uploadedFile);
}
const mirrorEvent = buildVideoMirrorEvent({
pubkey: normalizedPubkey,
if (!originalFileSha256 && originalFile) {
originalFileSha256 = await computeSha256HexFromValue(originalFile);
}
if (!originalFileSha256 && fileSha256) {
originalFileSha256 = fileSha256;
}
const mirrorOptions = {
url: finalUrl,
magnet: finalMagnet,
thumbnail: finalThumbnail,
description: finalDescription,
title: finalTitle,
mimeType,
isPrivate: contentObject.isPrivate,
actorPubkey: normalizedPubkey,
created_at: createdAt,
tags: mirrorTags,
content: altText,
});
};
devLogger.log("Prepared NIP-94 mirror event:", mirrorEvent);
if (fileSha256) {
mirrorOptions.fileSha256 = fileSha256;
}
if (originalFileSha256) {
mirrorOptions.originalFileSha256 = originalFileSha256;
}
try {
await this.signAndPublishEvent(mirrorEvent, {
context: "NIP-94 mirror",
logName: "NIP-94 mirror",
devLogLabel: "NIP-94 mirror",
rejectionLogLevel: "warn",
});
devLogger.log(
"NIP-94 mirror dispatched for hosted URL:",
finalUrl
const mirrorResult = await this.mirrorVideoEvent(
signedEvent.id,
mirrorOptions,
);
if (mirrorResult?.ok) {
devLogger.log("Prepared NIP-94 mirror event:", mirrorResult.event);
devLogger.log(
"NIP-94 mirror dispatched for hosted URL:",
finalUrl,
);
} else if (mirrorResult) {
devLogger.warn(
"[nostr] NIP-94 mirror rejected:",
mirrorResult.error || "mirror-failed",
mirrorResult.details || null,
);
}
} catch (mirrorError) {
devLogger.warn(
"[nostr] NIP-94 mirror rejected by all relays:",
mirrorError
"[nostr] Failed to publish NIP-94 mirror:",
mirrorError,
);
}
} else devLogger.log("Skipping NIP-94 mirror: no hosted URL provided.");
+34
View File
@@ -20,6 +20,22 @@ const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
const rebroadcastAttemptMemory = new Map();
const HEX_PRIVATE_KEY_REGEX = /^[0-9a-f]{64}$/i;
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
function normalizeSha256TagValue(value) {
if (value === undefined || value === null) {
return "";
}
const stringValue = typeof value === "string" ? value : String(value);
const trimmed = stringValue.trim().toLowerCase();
if (!trimmed) {
return "";
}
if (!HEX64_REGEX.test(trimmed)) {
return null;
}
return trimmed;
}
export function signEventWithPrivateKey(event, privateKey) {
const tools = getCachedNostrTools();
@@ -818,6 +834,18 @@ export async function mirrorVideoEvent({
const explicitAlt = sanitize(options.altText);
const altText = explicitAlt || description || title || "";
const fileSha256 = normalizeSha256TagValue(options.fileSha256);
if (fileSha256 === null) {
return { ok: false, error: "invalid-file-sha" };
}
const originalFileSha256 = normalizeSha256TagValue(
options.originalFileSha256,
);
if (originalFileSha256 === null) {
return { ok: false, error: "invalid-original-file-sha" };
}
const tags = [];
tags.push(["url", url]);
if (mimeType) {
@@ -832,6 +860,12 @@ export async function mirrorVideoEvent({
if (!isPrivate && magnet) {
tags.push(["magnet", magnet]);
}
if (fileSha256) {
tags.push(["x", fileSha256]);
}
if (originalFileSha256) {
tags.push(["ox", originalFileSha256]);
}
const additionalTags = Array.isArray(options.additionalTags)
? options.additionalTags.filter((tag) => Array.isArray(tag) && tag.length >= 2)
+16 -24
View File
@@ -110,42 +110,34 @@ test("mirrorVideoEvent lowercases inferred MIME types", async () => {
assert.equal(mTag[1], "video/webm");
});
test("repostEvent adds a target kind tag for quote reposts", async () => {
const eventId = "quote-repost";
test("mirrorVideoEvent includes hash tags when provided", async () => {
const eventId = "hash-tags";
const actorPubkey = "3".repeat(64);
const authorPubkey = "4".repeat(64);
const { client } = createPublishClient({ actorPubkey });
const fileHash = "a".repeat(64);
const originalHash = "b".repeat(64);
client.allEvents.set(eventId, {
kind: 30078,
pubkey: authorPubkey,
videoRootId: "video-root",
});
client.rawEvents.set(eventId, {
kind: 30078,
pubkey: authorPubkey,
tags: [["d", "video-root"]],
});
const result = await repostEvent({
const result = await mirrorVideoEvent({
client,
eventId,
options: {
actorPubkey,
authorPubkey,
kind: 30078,
url: "https://videos.example/demo.mp4",
fileSha256: fileHash.toUpperCase(),
originalFileSha256: originalHash,
},
resolveActiveSigner: resolveActiveSignerStub,
shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub,
signEventWithPrivateKey: signEventWithPrivateKeyStub,
eventToAddressPointer: () => "",
inferMimeTypeFromUrl: () => "video/mp4",
});
assert.equal(result.ok, true);
assert.equal(result.event?.kind, 16);
const kTag = result.event?.tags?.find(
(tag) => Array.isArray(tag) && tag[0] === "k",
const xTag = result.event?.tags?.find(
(tag) => Array.isArray(tag) && tag[0] === "x",
);
assert.ok(kTag, "repost events must include a target kind tag");
assert.equal(kTag[1], "30078");
const oxTag = result.event?.tags?.find(
(tag) => Array.isArray(tag) && tag[0] === "ox",
);
assert.deepEqual(xTag, ["x", fileHash]);
assert.deepEqual(oxTag, ["ox", originalHash]);
});