From b246e431e509b332117ffc091f2507a3595787f6 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 01:54:07 +0000 Subject: [PATCH] Refactor publishVideo method in NostrClient Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/client.js | 444 ++++---------------------------- js/nostr/videoPayloadBuilder.js | 304 ++++++++++++++++++++++ js/utils/cryptoUtils.js | 69 +++++ js/utils/hex.js | 12 + js/utils/mime.js | 49 ++++ tests/utils/hex.test.mjs | 20 ++ 6 files changed, 499 insertions(+), 399 deletions(-) create mode 100644 js/nostr/videoPayloadBuilder.js create mode 100644 js/utils/cryptoUtils.js create mode 100644 js/utils/mime.js diff --git a/js/nostr/client.js b/js/nostr/client.js index 1084c6ac..bb7e4ccb 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -19,7 +19,6 @@ import { isDevMode } from "../config.js"; import { FEATURE_PUBLISH_NIP71 } from "../constants.js"; -import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; import { infoHashFromMagnet } from "../magnets.js"; // 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable import { @@ -163,7 +162,7 @@ import { readStoredSessionActorEntry, isSessionActor, } from "./sessionActor.js"; -import { HEX64_REGEX } from "../utils/hex.js"; +import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js"; import { NIP46_RPC_KIND, NIP46_HANDSHAKE_TIMEOUT_MS, @@ -215,6 +214,11 @@ import { EventsMap } from "./eventsMap.js"; import { PersistenceManager } from "./managers/PersistenceManager.js"; import { ConnectionManager } from "./managers/ConnectionManager.js"; import { SignerManager } from "./managers/SignerManager.js"; +import { + prepareVideoPublishPayload, + prepareVideoMirrorOptions, +} from "./videoPayloadBuilder.js"; +import { inferMimeTypeFromUrl } from "../utils/mime.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -304,119 +308,6 @@ export const __testExports = { attemptDecryptNip46HandshakePayload, }; - -function extractVideoPublishPayload(rawPayload) { - let videoData = rawPayload; - let nip71Metadata = null; - - if (rawPayload && typeof rawPayload === "object") { - if (rawPayload.nip71 && typeof rawPayload.nip71 === "object") { - nip71Metadata = rawPayload.nip71; - } - if ( - rawPayload.legacyFormData && - typeof rawPayload.legacyFormData === "object" - ) { - videoData = rawPayload.legacyFormData; - } else if ( - Object.prototype.hasOwnProperty.call(rawPayload, "legacyFormData") - ) { - videoData = rawPayload.legacyFormData || {}; - } - } - - if (!videoData || typeof videoData !== "object") { - videoData = {}; - } - - const normalizedVideoData = { ...videoData }; - const isNsfw = videoData.isNsfw === true; - normalizedVideoData.isNsfw = isNsfw; - normalizedVideoData.isForKids = - videoData.isForKids === true && !isNsfw; - - 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 ""; @@ -494,57 +385,6 @@ function buildDmFilters(actorPubkey, { since, until, limit } = {}) { return filters; } - -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") { - return ""; - } - - let pathname = ""; - try { - const parsed = new URL(url); - pathname = parsed.pathname || ""; - } catch (err) { - const sanitized = url.split("?")[0].split("#")[0]; - pathname = sanitized || ""; - } - - const lastSegment = pathname.split("/").pop() || ""; - const match = lastSegment.match(/\.([a-z0-9]+)$/i); - if (!match) { - return ""; - } - - const extension = match[1].toLowerCase(); - const mimeType = EXTENSION_MIME_MAP[extension]; - return typeof mimeType === "string" ? mimeType : ""; -} - function getActiveKey(video) { if (video.videoRootId) { return `ROOT:${video.videoRootId}`; @@ -2487,154 +2327,28 @@ export class NostrClient { * @throws {Error} If not logged in or if the primary publish fails. */ async publishVideo(videoPayload, pubkey) { - if (!pubkey) throw new Error("Not logged in to publish video."); - - const normalizedPubkey = - typeof pubkey === "string" ? pubkey.trim() : ""; - - if (!normalizedPubkey) { - throw new Error("Not logged in to publish video."); - } + const { + event, + videoData, + nip71Metadata, + finalUrl, + finalMagnet, + finalThumbnail, + finalDescription, + finalTitle, + mimeType, + fileSha256, + originalFileSha256, + videoRootId, + dTagValue, + createdAt, + contentObject, + wantPrivate, + normalizedPubkey, + } = await prepareVideoPublishPayload(videoPayload, pubkey); const userPubkeyLower = normalizedPubkey.toLowerCase(); - const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); - const nip71EditedFlag = - videoPayload && typeof videoPayload === "object" - ? videoPayload.nip71Edited - : null; - - // NOTE: Keep the Upload, Edit, and Revert flows synchronized when - // updating shared fields. Changes here must be reflected in the modal - // controllers and revert helpers so all paths stay in lockstep. - devLogger.log("Publishing new video with data:", videoData); - if (nip71Metadata) { - devLogger.log("Including NIP-71 metadata:", nip71Metadata); - } - - const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : ""; - const finalMagnet = rawMagnet.trim(); - const finalUrl = - typeof videoData.url === "string" ? videoData.url.trim() : ""; - const finalThumbnail = - typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : ""; - const finalDescription = - typeof videoData.description === "string" - ? videoData.description.trim() - : ""; - const finalTitle = - typeof videoData.title === "string" ? videoData.title.trim() : ""; - const providedMimeType = - typeof videoData.mimeType === "string" - ? videoData.mimeType.trim().toLowerCase() - : ""; - - const createdAt = Math.floor(Date.now() / 1000); - - const seriesIdentifierCandidates = [ - videoData.videoRootId, - videoData.seriesId, - videoData.seriesIdentifier, - ]; - let seriesIdentifier = ""; - for (const candidate of seriesIdentifierCandidates) { - const normalized = typeof candidate === "string" ? candidate.trim() : ""; - if (normalized) { - seriesIdentifier = normalized; - break; - } - } - - if (!seriesIdentifier) { - seriesIdentifier = `${Date.now()}-${Math.random().toString(36).slice(2)}`; - } - - const videoRootId = seriesIdentifier; - const dTagValue = seriesIdentifier; - - const finalEnableComments = - videoData.enableComments === false ? false : true; - const finalIsNsfw = videoData.isNsfw === true; - const finalIsForKids = - videoData.isForKids === true && !finalIsNsfw; - const wantPrivate = videoData.isPrivate === true; - const finalWs = - typeof videoData.ws === "string" ? videoData.ws.trim() : ""; - const finalXs = - typeof videoData.xs === "string" ? videoData.xs.trim() : ""; - const infoHashCandidates = [videoData.infoHash]; - let infoHash = ""; - for (const candidate of infoHashCandidates) { - const normalized = infoHashFromMagnet(candidate); - if (normalized) { - infoHash = normalized; - break; - } - } - if (!infoHash && finalMagnet) { - infoHash = infoHashFromMagnet(finalMagnet) || ""; - } - const fileSha256 = normalizeHexHash(videoData.fileSha256); - const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256); - const storagePointer = resolveStoragePointerValue({ - storagePointer: videoData.storagePointer, - url: finalUrl, - infoHash, - fallbackId: videoRootId, - provider: videoData.storageProvider, - }); - - const contentObject = { - version: 3, - title: finalTitle, - url: finalUrl, - magnet: finalMagnet, - thumbnail: finalThumbnail, - description: finalDescription, - mode: videoData.mode || "live", - videoRootId, - deleted: false, - isPrivate: videoData.isPrivate ?? false, - isNsfw: finalIsNsfw, - isForKids: finalIsForKids, - enableComments: finalEnableComments, - }; - - if (infoHash) { - contentObject.infoHash = infoHash; - } - - if (fileSha256) { - contentObject.fileSha256 = fileSha256; - } - - if (originalFileSha256) { - contentObject.originalFileSha256 = originalFileSha256; - } - - if (finalWs) { - contentObject.ws = finalWs; - } - - if (finalXs) { - contentObject.xs = finalXs; - } - - const nip71Tags = buildNip71MetadataTags( - nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null - ); - - const additionalTags = storagePointer - ? [["s", storagePointer], ...nip71Tags] - : nip71Tags; - const event = buildVideoPostEvent({ - pubkey: normalizedPubkey, - created_at: createdAt, - dTagValue, - content: contentObject, - additionalTags, - }); - devLogger.log("Publish event with series identifier:", videoRootId); devLogger.log("Event content:", event.content); @@ -2648,90 +2362,22 @@ export class NostrClient { }); // 2. Publish NIP-94 Mirror (Kind 1063) if a hosted URL is present. - // This allows clients that only understand NIP-94 to find the file. if (finalUrl) { - const inferredMimeType = inferMimeTypeFromUrl(finalUrl); - const mimeTypeSource = - providedMimeType || - inferredMimeType || - "application/octet-stream"; - const mimeType = mimeTypeSource.toLowerCase(); - - const fileHashCandidates = [ - videoData.fileSha256, - videoData.uploadedFileSha256, - videoPayload?.legacyFormData?.fileSha256, - videoPayload?.fileSha256, - ]; - let fileSha256 = ""; - for (const candidate of fileHashCandidates) { - const normalized = normalizeHexHash(candidate); - if (normalized) { - fileSha256 = normalized; - break; - } - } - - 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; - } - } - - 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); - } - - if (!originalFileSha256 && originalFile) { - originalFileSha256 = await computeSha256HexFromValue(originalFile); - } - - if (!originalFileSha256 && fileSha256) { - originalFileSha256 = fileSha256; - } - - const mirrorOptions = { - url: finalUrl, - magnet: finalMagnet, - thumbnail: finalThumbnail, - description: finalDescription, - title: finalTitle, + const mirrorOptions = await prepareVideoMirrorOptions({ + videoData, + videoPayload, + finalUrl, + finalMagnet, + finalThumbnail, + finalDescription, + finalTitle, mimeType, + fileSha256, + originalFileSha256, + pubkey: normalizedPubkey, + createdAt, isPrivate: contentObject.isPrivate, - actorPubkey: normalizedPubkey, - created_at: createdAt, - }; - - if (fileSha256) { - mirrorOptions.fileSha256 = fileSha256; - } - - if (originalFileSha256) { - mirrorOptions.originalFileSha256 = originalFileSha256; - } + }); try { const mirrorResult = await this.mirrorVideoEvent( @@ -2741,10 +2387,7 @@ export class NostrClient { if (mirrorResult?.ok) { devLogger.log("Prepared NIP-94 mirror event:", mirrorResult.event); - devLogger.log( - "NIP-94 mirror dispatched for hosted URL:", - finalUrl, - ); + devLogger.log("NIP-94 mirror dispatched for hosted URL:", finalUrl); } else if (mirrorResult) { devLogger.warn( "[nostr] NIP-94 mirror rejected:", @@ -2761,7 +2404,10 @@ export class NostrClient { } else devLogger.log("Skipping NIP-94 mirror: no hosted URL provided."); // 3. Publish NIP-71 Metadata (Kind 22) if categories/tags were added. - // This is a separate event linked to the video via `a` tag or `e` tag. + const nip71EditedFlag = + videoPayload && typeof videoPayload === "object" + ? videoPayload.nip71Edited + : null; const hasMetadataObject = nip71Metadata && typeof nip71Metadata === "object"; const metadataWasEdited = @@ -2801,12 +2447,12 @@ export class NostrClient { videoRootId, dTag: dTagValue, eventId: signedEvent.id, - } + }, ); } catch (nip71Error) { userLogger.warn( "[nostr] Failed to publish NIP-71 metadata for edit:", - nip71Error + nip71Error, ); } } diff --git a/js/nostr/videoPayloadBuilder.js b/js/nostr/videoPayloadBuilder.js new file mode 100644 index 00000000..f7a1252c --- /dev/null +++ b/js/nostr/videoPayloadBuilder.js @@ -0,0 +1,304 @@ +import { HEX64_REGEX, normalizeHexHash } from "../utils/hex.js"; +import { infoHashFromMagnet } from "../magnets.js"; +import { resolveStoragePointerValue } from "../utils/storagePointer.js"; +import { buildNip71MetadataTags } from "./nip71.js"; +import { buildVideoPostEvent } from "../nostrEventSchemas.js"; +import { computeSha256HexFromValue } from "../utils/cryptoUtils.js"; +import { inferMimeTypeFromUrl } from "../utils/mime.js"; +import { devLogger } from "../utils/logger.js"; + +export function extractVideoPublishPayload(rawPayload) { + let videoData = rawPayload; + let nip71Metadata = null; + + if (rawPayload && typeof rawPayload === "object") { + if (rawPayload.nip71 && typeof rawPayload.nip71 === "object") { + nip71Metadata = rawPayload.nip71; + } + if ( + rawPayload.legacyFormData && + typeof rawPayload.legacyFormData === "object" + ) { + videoData = rawPayload.legacyFormData; + } else if ( + Object.prototype.hasOwnProperty.call(rawPayload, "legacyFormData") + ) { + videoData = rawPayload.legacyFormData || {}; + } + } + + if (!videoData || typeof videoData !== "object") { + videoData = {}; + } + + const normalizedVideoData = { ...videoData }; + const isNsfw = videoData.isNsfw === true; + normalizedVideoData.isNsfw = isNsfw; + normalizedVideoData.isForKids = + videoData.isForKids === true && !isNsfw; + + return { videoData: normalizedVideoData, nip71Metadata }; +} + +export async function prepareVideoPublishPayload(videoPayload, pubkey, { timestamp = Math.floor(Date.now() / 1000) } = {}) { + const normalizedPubkey = typeof pubkey === "string" ? pubkey.trim() : ""; + if (!normalizedPubkey) { + throw new Error("Not logged in to publish video."); + } + + const { videoData, nip71Metadata } = extractVideoPublishPayload(videoPayload); + + devLogger.log("Publishing new video with data:", videoData); + if (nip71Metadata) { + devLogger.log("Including NIP-71 metadata:", nip71Metadata); + } + + const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : ""; + const finalMagnet = rawMagnet.trim(); + const finalUrl = + typeof videoData.url === "string" ? videoData.url.trim() : ""; + const finalThumbnail = + typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : ""; + const finalDescription = + typeof videoData.description === "string" + ? videoData.description.trim() + : ""; + const finalTitle = + typeof videoData.title === "string" ? videoData.title.trim() : ""; + const providedMimeType = + typeof videoData.mimeType === "string" + ? videoData.mimeType.trim().toLowerCase() + : ""; + + const createdAt = timestamp; + + const seriesIdentifierCandidates = [ + videoData.videoRootId, + videoData.seriesId, + videoData.seriesIdentifier, + ]; + let seriesIdentifier = ""; + for (const candidate of seriesIdentifierCandidates) { + const normalized = typeof candidate === "string" ? candidate.trim() : ""; + if (normalized) { + seriesIdentifier = normalized; + break; + } + } + + if (!seriesIdentifier) { + seriesIdentifier = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + const videoRootId = seriesIdentifier; + const dTagValue = seriesIdentifier; + + const finalEnableComments = + videoData.enableComments === false ? false : true; + const finalIsNsfw = videoData.isNsfw === true; + const finalIsForKids = + videoData.isForKids === true && !finalIsNsfw; + const wantPrivate = videoData.isPrivate === true; + const finalWs = + typeof videoData.ws === "string" ? videoData.ws.trim() : ""; + const finalXs = + typeof videoData.xs === "string" ? videoData.xs.trim() : ""; + const infoHashCandidates = [videoData.infoHash]; + let infoHash = ""; + for (const candidate of infoHashCandidates) { + const normalized = infoHashFromMagnet(candidate); + if (normalized) { + infoHash = normalized; + break; + } + } + if (!infoHash && finalMagnet) { + infoHash = infoHashFromMagnet(finalMagnet) || ""; + } + const fileSha256 = normalizeHexHash(videoData.fileSha256); + const originalFileSha256 = normalizeHexHash(videoData.originalFileSha256); + const storagePointer = resolveStoragePointerValue({ + storagePointer: videoData.storagePointer, + url: finalUrl, + infoHash, + fallbackId: videoRootId, + provider: videoData.storageProvider, + }); + + const contentObject = { + version: 3, + title: finalTitle, + url: finalUrl, + magnet: finalMagnet, + thumbnail: finalThumbnail, + description: finalDescription, + mode: videoData.mode || "live", + videoRootId, + deleted: false, + isPrivate: videoData.isPrivate ?? false, + isNsfw: finalIsNsfw, + isForKids: finalIsForKids, + enableComments: finalEnableComments, + }; + + if (infoHash) { + contentObject.infoHash = infoHash; + } + + if (fileSha256) { + contentObject.fileSha256 = fileSha256; + } + + if (originalFileSha256) { + contentObject.originalFileSha256 = originalFileSha256; + } + + if (finalWs) { + contentObject.ws = finalWs; + } + + if (finalXs) { + contentObject.xs = finalXs; + } + + const nip71Tags = buildNip71MetadataTags( + nip71Metadata && typeof nip71Metadata === "object" ? nip71Metadata : null + ); + + const additionalTags = storagePointer + ? [["s", storagePointer], ...nip71Tags] + : nip71Tags; + + const event = buildVideoPostEvent({ + pubkey: normalizedPubkey, + created_at: createdAt, + dTagValue, + content: contentObject, + additionalTags, + }); + + return { + event, + videoData, + nip71Metadata, + finalUrl, + finalMagnet, + finalThumbnail, + finalDescription, + finalTitle, + mimeType: providedMimeType, + fileSha256, + originalFileSha256, + videoRootId, + dTagValue, + createdAt, + contentObject, + wantPrivate, + normalizedPubkey + }; +} + +export async function prepareVideoMirrorOptions(params) { + const { + videoData, + videoPayload, + finalUrl, + finalMagnet, + finalThumbnail, + finalDescription, + finalTitle, + mimeType: providedMimeType, + fileSha256: initialFileSha256, + originalFileSha256: initialOriginalFileSha256, + pubkey, + createdAt, + isPrivate + } = params; + + const inferredMimeType = inferMimeTypeFromUrl(finalUrl); + const mimeTypeSource = + providedMimeType || + inferredMimeType || + "application/octet-stream"; + const mimeType = mimeTypeSource.toLowerCase(); + + const fileHashCandidates = [ + initialFileSha256, + videoData.fileSha256, + videoData.uploadedFileSha256, + videoPayload?.legacyFormData?.fileSha256, + videoPayload?.fileSha256, + ]; + let fileSha256 = ""; + for (const candidate of fileHashCandidates) { + const normalized = normalizeHexHash(candidate); + if (normalized) { + fileSha256 = normalized; + break; + } + } + + const originalHashCandidates = [ + initialOriginalFileSha256, + videoData.originalFileSha256, + videoPayload?.legacyFormData?.originalFileSha256, + videoPayload?.originalFileSha256, + ]; + let originalFileSha256 = ""; + for (const candidate of originalHashCandidates) { + const normalized = normalizeHexHash(candidate); + if (normalized) { + originalFileSha256 = normalized; + break; + } + } + + 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); + } + + 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: isPrivate, + actorPubkey: pubkey, + created_at: createdAt, + }; + + if (fileSha256) { + mirrorOptions.fileSha256 = fileSha256; + } + + if (originalFileSha256) { + mirrorOptions.originalFileSha256 = originalFileSha256; + } + + return mirrorOptions; +} diff --git a/js/utils/cryptoUtils.js b/js/utils/cryptoUtils.js new file mode 100644 index 00000000..b99b26f8 --- /dev/null +++ b/js/utils/cryptoUtils.js @@ -0,0 +1,69 @@ +import { bytesToHex, sha256 } from "../../vendor/crypto-helpers.bundle.min.js"; +import { devLogger } from "./logger.js"; + +let sharedTextEncoder = null; + +export function getSharedTextEncoder() { + if (!sharedTextEncoder && typeof TextEncoder !== "undefined") { + sharedTextEncoder = new TextEncoder(); + } + return sharedTextEncoder; +} + +const BlobConstructor = typeof Blob !== "undefined" ? Blob : null; + +export 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; +} + +export 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 ""; + } +} diff --git a/js/utils/hex.js b/js/utils/hex.js index e0cfca22..49307eb0 100644 --- a/js/utils/hex.js +++ b/js/utils/hex.js @@ -11,3 +11,15 @@ export function normalizeHexString(value) { export const normalizeHexId = normalizeHexString; export const normalizeHexPubkey = normalizeHexString; + +export function normalizeHexHash(value) { + if (value === undefined || value === null) { + return ""; + } + const stringValue = typeof value === "string" ? value : String(value); + const trimmed = stringValue.trim().toLowerCase(); + if (!trimmed) { + return ""; + } + return HEX64_REGEX.test(trimmed) ? trimmed : ""; +} diff --git a/js/utils/mime.js b/js/utils/mime.js new file mode 100644 index 00000000..487cf3f5 --- /dev/null +++ b/js/utils/mime.js @@ -0,0 +1,49 @@ +export 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() : "", + ]), + ), +); + +export function inferMimeTypeFromUrl(url) { + if (!url || typeof url !== "string") { + return ""; + } + + let pathname = ""; + try { + const parsed = new URL(url); + pathname = parsed.pathname || ""; + } catch (err) { + const sanitized = url.split("?")[0].split("#")[0]; + pathname = sanitized || ""; + } + + const lastSegment = pathname.split("/").pop() || ""; + const match = lastSegment.match(/\.([a-z0-9]+)$/i); + if (!match) { + return ""; + } + + const extension = match[1].toLowerCase(); + const mimeType = EXTENSION_MIME_MAP[extension]; + return typeof mimeType === "string" ? mimeType : ""; +} diff --git a/tests/utils/hex.test.mjs b/tests/utils/hex.test.mjs index 0d6fe98f..ec1f4e68 100644 --- a/tests/utils/hex.test.mjs +++ b/tests/utils/hex.test.mjs @@ -4,6 +4,7 @@ import { normalizeHexString, normalizeHexId, normalizeHexPubkey, + normalizeHexHash, HEX64_REGEX, } from "../../js/utils/hex.js"; @@ -60,4 +61,23 @@ describe("hex utils", () => { assert.doesNotMatch("", HEX64_REGEX); }); }); + + describe("normalizeHexHash", () => { + it("should return empty string for non-string inputs", () => { + assert.equal(normalizeHexHash(null), ""); + assert.equal(normalizeHexHash(undefined), ""); + assert.equal(normalizeHexHash(123), ""); + }); + + it("should return empty string for invalid hex", () => { + assert.equal(normalizeHexHash("invalid"), ""); + assert.equal(normalizeHexHash("g".repeat(64)), ""); + }); + + it("should return normalized hex for valid inputs", () => { + const valid = "A".repeat(64); + const expected = "a".repeat(64); + assert.equal(normalizeHexHash(valid), expected); + }); + }); });