Merge pull request #1001 from PR0M3TH3AN/codex/update-mime-handling-for-nip-94-compliance

Normalize NIP-94 MIME handling
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-29 21:36:04 -04:00
commit 7556d8d265
5 files changed
+180 -40

No files matched your search

+2
View File
@@ -152,6 +152,8 @@ import { updateWatchHistoryListWithDefaultClient } from "./nostrWatchHistoryFaca
| Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', <pubkey>]` entries | Empty content |
| Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', <pubkey>]` entries | Empty content |
NIP-94 compliance: mirror events now normalize the `['m', <mime>]` 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:
+32 -21
View File
@@ -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],
+26 -18
View File
@@ -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 = {}) {
+11 -1
View File
@@ -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 || "";
+109
View File
@@ -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");
});