diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index ebd4e3b2..bcf792e7 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -152,6 +152,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | +NIP-94 compliance: mirror events now normalize the `['m', ]` tag to lowercase—covering both user input and inferred values—so they satisfy [NIP-94's lowercase MIME requirement](./nips/94.md). Related helpers reuse the same normalization to keep future publishers aligned. + ### Direct messages bitvid now consumes both legacy direct messages (kind `4`) and modern gift-wrap envelopes (kind `1059`). The `js/dmDecryptor.js` helper unwraps these events by: diff --git a/js/nostr/client.js b/js/nostr/client.js index 7483262f..1572ef1e 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -694,23 +694,30 @@ function buildDmFilters(actorPubkey, { since, until, limit } = {}) { } -const EXTENSION_MIME_MAP = { - mp4: "video/mp4", - m4v: "video/x-m4v", - webm: "video/webm", - mkv: "video/x-matroska", - mov: "video/quicktime", - avi: "video/x-msvideo", - ogv: "video/ogg", - ogg: "video/ogg", - m3u8: "application/x-mpegURL", - mpd: "application/dash+xml", - ts: "video/mp2t", - mpg: "video/mpeg", - mpeg: "video/mpeg", - flv: "video/x-flv", - "3gp": "video/3gpp", -}; +const EXTENSION_MIME_MAP = Object.freeze( + Object.fromEntries( + Object.entries({ + mp4: "video/mp4", + m4v: "video/x-m4v", + webm: "video/webm", + mkv: "video/x-matroska", + mov: "video/quicktime", + avi: "video/x-msvideo", + ogv: "video/ogg", + ogg: "video/ogg", + m3u8: "application/x-mpegurl", + mpd: "application/dash+xml", + ts: "video/mp2t", + mpg: "video/mpeg", + mpeg: "video/mpeg", + flv: "video/x-flv", + "3gp": "video/3gpp", + }).map(([extension, mimeType]) => [ + extension, + typeof mimeType === "string" ? mimeType.toLowerCase() : "", + ]), + ), +); function inferMimeTypeFromUrl(url) { if (!url || typeof url !== "string") { @@ -733,7 +740,8 @@ function inferMimeTypeFromUrl(url) { } const extension = match[1].toLowerCase(); - return EXTENSION_MIME_MAP[extension] || ""; + const mimeType = EXTENSION_MIME_MAP[extension]; + return typeof mimeType === "string" ? mimeType : ""; } function getActiveKey(video) { @@ -3932,7 +3940,7 @@ export class NostrClient { typeof videoData.title === "string" ? videoData.title.trim() : ""; const providedMimeType = typeof videoData.mimeType === "string" - ? videoData.mimeType.trim() + ? videoData.mimeType.trim().toLowerCase() : ""; const createdAt = Math.floor(Date.now() / 1000); @@ -4000,8 +4008,11 @@ export class NostrClient { if (finalUrl) { const inferredMimeType = inferMimeTypeFromUrl(finalUrl); - const mimeType = - providedMimeType || inferredMimeType || "application/octet-stream"; + const mimeTypeSource = + providedMimeType || + inferredMimeType || + "application/octet-stream"; + const mimeType = mimeTypeSource.toLowerCase(); const mirrorTags = [ ["url", finalUrl], diff --git a/js/nostr/nip71.js b/js/nostr/nip71.js index 0024dbb7..41f1ad0c 100644 --- a/js/nostr/nip71.js +++ b/js/nostr/nip71.js @@ -1174,23 +1174,30 @@ export async function populateNip71MetadataForVideos(videos = [], { }); } -const EXTENSION_MIME_MAP = { - mp4: "video/mp4", - m4v: "video/x-m4v", - webm: "video/webm", - mkv: "video/x-matroska", - mov: "video/quicktime", - avi: "video/x-msvideo", - ogv: "video/ogg", - ogg: "video/ogg", - m3u8: "application/x-mpegURL", - mpd: "application/dash+xml", - ts: "video/mp2t", - mpg: "video/mpeg", - mpeg: "video/mpeg", - flv: "video/x-flv", - "3gp": "video/3gpp", -}; +const EXTENSION_MIME_MAP = Object.freeze( + Object.fromEntries( + Object.entries({ + mp4: "video/mp4", + m4v: "video/x-m4v", + webm: "video/webm", + mkv: "video/x-matroska", + mov: "video/quicktime", + avi: "video/x-msvideo", + ogv: "video/ogg", + ogg: "video/ogg", + m3u8: "application/x-mpegurl", + mpd: "application/dash+xml", + ts: "video/mp2t", + mpg: "video/mpeg", + mpeg: "video/mpeg", + flv: "video/x-flv", + "3gp": "video/3gpp", + }).map(([extension, mimeType]) => [ + extension, + typeof mimeType === "string" ? mimeType.toLowerCase() : "", + ]), + ), +); function inferMimeTypeFromUrl(url) { if (!url || typeof url !== "string") { @@ -1213,7 +1220,8 @@ function inferMimeTypeFromUrl(url) { } const extension = match[1].toLowerCase(); - return EXTENSION_MIME_MAP[extension] || ""; + const mimeType = EXTENSION_MIME_MAP[extension]; + return typeof mimeType === "string" ? mimeType : ""; } export function convertEventToVideo(event = {}) { diff --git a/js/nostr/publishHelpers.js b/js/nostr/publishHelpers.js index 79936110..fd9b6749 100644 --- a/js/nostr/publishHelpers.js +++ b/js/nostr/publishHelpers.js @@ -791,10 +791,20 @@ export async function mirrorVideoEvent({ } const providedMimeType = sanitize(options.mimeType); + const normalizedProvidedMimeType = providedMimeType + ? providedMimeType.toLowerCase() + : ""; const inferredMimeType = inferMimeTypeFromUrl ? inferMimeTypeFromUrl(url) : ""; - const mimeType = providedMimeType || inferredMimeType || "application/octet-stream"; + const normalizedInferredMimeType = inferredMimeType + ? inferredMimeType.toLowerCase() + : ""; + const mimeTypeSource = + normalizedProvidedMimeType || + normalizedInferredMimeType || + "application/octet-stream"; + const mimeType = mimeTypeSource.toLowerCase(); const explicitAlt = sanitize(options.altText); const altText = explicitAlt || description || title || ""; diff --git a/tests/nostr/publishHelpers.test.mjs b/tests/nostr/publishHelpers.test.mjs new file mode 100644 index 00000000..164c56a1 --- /dev/null +++ b/tests/nostr/publishHelpers.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { mirrorVideoEvent } = await import("../../js/nostr/publishHelpers.js"); + +const resolveActiveSignerStub = () => ({ + signEvent: async (event) => ({ + ...event, + id: `${event.kind}-${event.created_at}`, + sig: "active-signature", + }), +}); + +const shouldRequestExtensionPermissionsStub = () => false; + +const signEventWithPrivateKeyStub = (event) => ({ + ...event, + id: `${event.kind}-${event.created_at}-session`, + sig: "session-signature", +}); + +function createPublishClient({ actorPubkey = "" } = {}) { + const publishCalls = []; + const pool = { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + handler(); + } + return true; + }, + }; + }, + }; + + const client = { + pubkey: actorPubkey, + relays: ["wss://relay.example"], + writeRelays: ["wss://relay.example"], + pool, + allEvents: new Map(), + rawEvents: new Map(), + ensurePool: async () => pool, + ensureSessionActor: async () => actorPubkey, + ensureExtensionPermissions: async () => ({ ok: true }), + }; + + if (actorPubkey) { + client.sessionActor = { + pubkey: actorPubkey, + privateKey: "session-private-key", + }; + } + + return { client, publishCalls }; +} + +test("mirrorVideoEvent lowercases provided MIME types", async () => { + const eventId = "provided-mime"; + const actorPubkey = "1".repeat(64); + const { client } = createPublishClient({ actorPubkey }); + + const result = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://videos.example/demo.mp4", + mimeType: "Video/MP4", + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + inferMimeTypeFromUrl: () => "", + }); + + assert.equal(result.ok, true); + const mTag = result.event?.tags?.find( + (tag) => Array.isArray(tag) && tag[0] === "m", + ); + assert.ok(mTag, "mirror events must include a MIME tag"); + assert.equal(mTag[1], "video/mp4"); +}); + +test("mirrorVideoEvent lowercases inferred MIME types", async () => { + const eventId = "inferred-mime"; + const actorPubkey = "2".repeat(64); + const { client } = createPublishClient({ actorPubkey }); + + const result = await mirrorVideoEvent({ + client, + eventId, + options: { + url: "https://videos.example/demo.webm", + }, + resolveActiveSigner: resolveActiveSignerStub, + shouldRequestExtensionPermissions: shouldRequestExtensionPermissionsStub, + signEventWithPrivateKey: signEventWithPrivateKeyStub, + inferMimeTypeFromUrl: () => "VIDEO/WEBM", + }); + + assert.equal(result.ok, true); + const mTag = result.event?.tags?.find( + (tag) => Array.isArray(tag) && tag[0] === "m", + ); + assert.ok(mTag, "mirror events must include a MIME tag"); + assert.equal(mTag[1], "video/webm"); +});