mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
Merge pull request #2265 from PR0M3TH3AN/refactor-publishVideo-method-129817906368864748
Refactor publishVideo method in NostrClient
This commit is contained in:
16 files changed
+729
-631
No files matched your search
+70
-470
@@ -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 {
|
||||
@@ -164,7 +163,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,
|
||||
@@ -221,6 +220,12 @@ import {
|
||||
hydrateExtensionSignerCapabilities,
|
||||
attachNipMethodAliases,
|
||||
} from "./managers/SignerManager.js";
|
||||
import { RelayBatchFetcher } from "./relayBatchFetcher.js";
|
||||
import {
|
||||
prepareVideoPublishPayload,
|
||||
prepareVideoMirrorOptions,
|
||||
} from "./videoPayloadBuilder.js";
|
||||
import { inferMimeTypeFromUrl } from "../utils/mime.js";
|
||||
|
||||
function normalizeProfileFromEvent(event) {
|
||||
if (!event || !event.content) return null;
|
||||
@@ -239,48 +244,19 @@ function resolveActiveSigner(pubkey) {
|
||||
const signer = resolveActiveSignerFromRegistry(pubkey);
|
||||
hydrateExtensionSignerCapabilities(signer);
|
||||
attachNipMethodAliases(signer);
|
||||
if (signer && typeof signer === "object") {
|
||||
const capsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
signer,
|
||||
"capabilities",
|
||||
);
|
||||
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
|
||||
|
||||
if (!isGetter) {
|
||||
signer.capabilities = resolveSignerCapabilities(signer);
|
||||
}
|
||||
}
|
||||
return signer;
|
||||
}
|
||||
|
||||
function setActiveSigner(signer) {
|
||||
if (signer && typeof signer === "object") {
|
||||
hydrateExtensionSignerCapabilities(signer);
|
||||
attachNipMethodAliases(signer);
|
||||
const capsDescriptor = Object.getOwnPropertyDescriptor(signer, "capabilities");
|
||||
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
|
||||
if (!isGetter) {
|
||||
signer.capabilities = resolveSignerCapabilities(signer);
|
||||
}
|
||||
}
|
||||
hydrateExtensionSignerCapabilities(signer);
|
||||
attachNipMethodAliases(signer);
|
||||
setActiveSignerInRegistry(signer);
|
||||
}
|
||||
|
||||
function getActiveSigner() {
|
||||
const signer = getActiveSignerFromRegistry();
|
||||
if (signer && typeof signer === "object") {
|
||||
hydrateExtensionSignerCapabilities(signer);
|
||||
attachNipMethodAliases(signer);
|
||||
const capsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
signer,
|
||||
"capabilities",
|
||||
);
|
||||
const isGetter = capsDescriptor && typeof capsDescriptor.get === "function";
|
||||
|
||||
if (!isGetter) {
|
||||
signer.capabilities = resolveSignerCapabilities(signer);
|
||||
}
|
||||
}
|
||||
hydrateExtensionSignerCapabilities(signer);
|
||||
attachNipMethodAliases(signer);
|
||||
return signer;
|
||||
}
|
||||
|
||||
@@ -347,119 +323,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 "";
|
||||
@@ -537,57 +400,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}`;
|
||||
@@ -667,6 +479,7 @@ export class NostrClient {
|
||||
this.dirtyTombstones = new Set();
|
||||
this.persistenceManager = new PersistenceManager(this);
|
||||
this.syncMetadataStore = new SyncMetadataStore();
|
||||
this.relayBatchFetcher = new RelayBatchFetcher(this);
|
||||
|
||||
this.nip71Cache = new Map();
|
||||
this.watchHistory = createWatchHistoryManager({
|
||||
@@ -2542,6 +2355,21 @@ export class NostrClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a local private key (nsec) as the active signer.
|
||||
* Useful for tests or manual key management.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.privateKey - The hex private key.
|
||||
* @param {string} [opts.pubkey] - Optional hex public key.
|
||||
* @returns {object} The created signer adapter.
|
||||
*/
|
||||
async registerPrivateKeySigner(opts) {
|
||||
const adapter = await createNsecAdapter(opts);
|
||||
this.signerManager.setActiveSigner(adapter);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new video event (Kind 30078) to the network.
|
||||
*
|
||||
@@ -2561,154 +2389,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);
|
||||
|
||||
@@ -2722,90 +2424,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(
|
||||
@@ -2815,10 +2449,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:",
|
||||
@@ -2835,7 +2466,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 =
|
||||
@@ -2875,12 +2509,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3007,42 +2641,8 @@ export class NostrClient {
|
||||
return this.signerManager.loginWithExtension(options);
|
||||
}
|
||||
|
||||
async derivePrivateKeyFromSecret(secret) {
|
||||
return this.signerManager.derivePrivateKeyFromSecret(secret);
|
||||
}
|
||||
|
||||
async registerPrivateKeySigner(params) {
|
||||
let { pubkey } = params || {};
|
||||
const { privateKey } = params || {};
|
||||
|
||||
if (!pubkey && privateKey) {
|
||||
try {
|
||||
const tools = await ensureNostrTools();
|
||||
if (tools && typeof tools.getPublicKey === "function") {
|
||||
pubkey = tools.getPublicKey(privateKey);
|
||||
}
|
||||
} catch (err) {
|
||||
devLogger.warn("[nostr] Failed to derive pubkey in registerPrivateKeySigner:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveParams = { ...params, pubkey };
|
||||
const resultPubkey = await this.signerManager.registerPrivateKeySigner(effectiveParams);
|
||||
|
||||
// Explicitly update the session actor so worker-based encryption can use the private key
|
||||
// even if it wasn't persisted by the SignerManager.
|
||||
if (privateKey) {
|
||||
this.sessionActor = {
|
||||
privateKey,
|
||||
pubkey: resultPubkey,
|
||||
source: "nsec",
|
||||
};
|
||||
}
|
||||
return resultPubkey;
|
||||
}
|
||||
|
||||
installNip46Client(rpcClient, options) {
|
||||
return this.signerManager.installNip46Client(rpcClient, options);
|
||||
installNip46Client(client, options) {
|
||||
return this.signerManager.installNip46Client(client, options);
|
||||
}
|
||||
|
||||
async connectRemoteSigner(params) {
|
||||
@@ -3080,8 +2680,8 @@ export class NostrClient {
|
||||
return this.signerManager.disconnectRemoteSigner(options);
|
||||
}
|
||||
|
||||
async ensureSessionActor(...args) {
|
||||
return this.signerManager.ensureSessionActor(...args);
|
||||
async ensureSessionActor(force) {
|
||||
return this.signerManager.ensureSessionActor(force);
|
||||
}
|
||||
|
||||
clearStoredSessionActor() {
|
||||
|
||||
@@ -264,21 +264,12 @@ export class SignerManager {
|
||||
this.extensionReady = false;
|
||||
this.extensionPermissionsGranted = false;
|
||||
this.extensionPermissionCache = new Map();
|
||||
|
||||
// Hydrate permission cache from localStorage so fresh instances
|
||||
// recognise previously granted permissions without re-prompting.
|
||||
try {
|
||||
const storedPermissions = readStoredNip07Permissions();
|
||||
if (storedPermissions.size > 0) {
|
||||
const storedPermissions = readStoredNip07Permissions();
|
||||
if (storedPermissions && storedPermissions.size > 0) {
|
||||
for (const method of storedPermissions) {
|
||||
this.extensionPermissionCache.set(method, true);
|
||||
this.extensionPermissionCache.set(method, true);
|
||||
}
|
||||
this.extensionPermissionsGranted = true;
|
||||
}
|
||||
} catch (_) {
|
||||
// Storage unavailable or corrupt — start with empty cache
|
||||
}
|
||||
|
||||
this.sessionActorCipherClosures = null;
|
||||
this.sessionActorCipherClosuresPrivateKey = null;
|
||||
this.remoteSignerListeners = new Set();
|
||||
@@ -325,57 +316,56 @@ export class SignerManager {
|
||||
return resolveActiveSigner(pubkey);
|
||||
}
|
||||
|
||||
async ensureSessionActor(createIfMissing = false) {
|
||||
if (this.sessionActor) {
|
||||
return this.sessionActor;
|
||||
}
|
||||
|
||||
if (this.lockedSessionActor) {
|
||||
return this.lockedSessionActor;
|
||||
}
|
||||
|
||||
const storedEntry = readStoredSessionActorEntry();
|
||||
if (storedEntry) {
|
||||
this.lockedSessionActor = storedEntry;
|
||||
this.sessionActor = storedEntry;
|
||||
return storedEntry;
|
||||
}
|
||||
|
||||
if (createIfMissing) {
|
||||
const tools = (await ensureNostrTools()) || getCachedNostrTools();
|
||||
if (tools) {
|
||||
let privateKey;
|
||||
let pubkey;
|
||||
|
||||
if (typeof tools.generatePrivateKey === "function") {
|
||||
const raw = tools.generatePrivateKey();
|
||||
if (typeof raw === "string") {
|
||||
privateKey = raw;
|
||||
pubkey = tools.getPublicKey(privateKey);
|
||||
} else {
|
||||
pubkey = tools.getPublicKey(raw);
|
||||
privateKey = Array.from(raw)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
} else if (typeof tools.generateSecretKey === "function") {
|
||||
const secret = tools.generateSecretKey();
|
||||
pubkey = tools.getPublicKey(secret);
|
||||
privateKey = Array.from(secret)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (privateKey && pubkey) {
|
||||
const newActor = { privateKey, pubkey };
|
||||
this.sessionActor = newActor;
|
||||
persistSessionActorEntry(newActor);
|
||||
return newActor;
|
||||
}
|
||||
async ensureSessionActor(force = false) {
|
||||
if (!force) {
|
||||
if (this.sessionActor) {
|
||||
return this.sessionActor.pubkey;
|
||||
}
|
||||
if (this.lockedSessionActor) {
|
||||
return this.lockedSessionActor.pubkey;
|
||||
}
|
||||
const storedEntry = readStoredSessionActorEntry();
|
||||
if (storedEntry) {
|
||||
this.lockedSessionActor = storedEntry;
|
||||
this.sessionActor = storedEntry;
|
||||
return storedEntry.pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
const tools = await ensureNostrTools();
|
||||
if (!tools) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let secret;
|
||||
try {
|
||||
if (typeof tools.generateSecretKey === "function") {
|
||||
secret = tools.generateSecretKey();
|
||||
} else if (typeof window !== "undefined" && window.crypto) {
|
||||
secret = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(secret);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to generate session actor key:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hexKey = Array.from(secret)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
const pubkey = tools.getPublicKey(secret);
|
||||
|
||||
const newActor = {
|
||||
pubkey,
|
||||
privateKey: hexKey,
|
||||
source: "session",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.sessionActor = newActor;
|
||||
return newActor.pubkey;
|
||||
}
|
||||
|
||||
clearStoredSessionActor() {
|
||||
@@ -479,7 +469,7 @@ export class SignerManager {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
let extension;
|
||||
let extension = null;
|
||||
try {
|
||||
extension = await waitForNip07Extension();
|
||||
} catch (error) {
|
||||
@@ -498,7 +488,7 @@ export class SignerManager {
|
||||
writeStoredNip07Permissions(missing);
|
||||
this.extensionPermissionsGranted = true;
|
||||
this.extensionPermissionCache.set(cacheKey, true);
|
||||
for (const method of methods) {
|
||||
for (const method of missing) {
|
||||
this.extensionPermissionCache.set(method, true);
|
||||
}
|
||||
return { ok: true };
|
||||
@@ -599,9 +589,7 @@ export class SignerManager {
|
||||
const adapter = {
|
||||
type: "extension",
|
||||
pubkey: normalized,
|
||||
signEvent: typeof extension.signEvent === "function"
|
||||
? extension.signEvent.bind(extension)
|
||||
: undefined,
|
||||
signEvent: typeof extension.signEvent === "function" ? extension.signEvent.bind(extension) : undefined,
|
||||
nip04: extension.nip04,
|
||||
nip44: extension.nip44,
|
||||
};
|
||||
@@ -610,6 +598,22 @@ export class SignerManager {
|
||||
return { pubkey: normalized, signer: adapter };
|
||||
}
|
||||
|
||||
installNip46Client(nip46Client, { userPubkey } = {}) {
|
||||
if (!nip46Client) {
|
||||
return;
|
||||
}
|
||||
this.nip46Client = nip46Client;
|
||||
if (userPubkey) {
|
||||
this.pubkey = userPubkey;
|
||||
this.setActiveSigner(nip46Client);
|
||||
}
|
||||
this.emitRemoteSignerChange({
|
||||
state: "connected",
|
||||
userPubkey: userPubkey || this.pubkey,
|
||||
remotePubkey: nip46Client.remotePubkey,
|
||||
});
|
||||
}
|
||||
|
||||
async connectRemoteSigner({
|
||||
connectionString,
|
||||
remember = true,
|
||||
|
||||
@@ -488,13 +488,10 @@ export function isSessionActor(nostrClient) {
|
||||
return false;
|
||||
}
|
||||
const clientPubkey =
|
||||
typeof nostrClient?.pubkey === "string" ? nostrClient.pubkey : null;
|
||||
const sessionPubkey = typeof sa.pubkey === "string" ? sa.pubkey : null;
|
||||
typeof nostrClient.pubkey === "string" ? nostrClient.pubkey : "";
|
||||
const saPubkey = typeof sa.pubkey === "string" ? sa.pubkey : "";
|
||||
|
||||
if (clientPubkey && sessionPubkey && clientPubkey !== sessionPubkey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return clientPubkey === saPubkey;
|
||||
}
|
||||
|
||||
function _closeSessionActorDb() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -176,14 +176,22 @@ export function setActiveSigner(signerOrPubkey) {
|
||||
if (typeof signerOrPubkey === "string") {
|
||||
const normalized = normalizePubkey(signerOrPubkey);
|
||||
const entry = registeredSigners.get(normalized);
|
||||
updateActiveSigner(entry?.signer || null, normalized);
|
||||
return entry?.signer || null;
|
||||
if (entry) {
|
||||
updateActiveSigner(entry.signer, normalized);
|
||||
return entry.signer;
|
||||
}
|
||||
// If not found, do not clear active signer just yet?
|
||||
// Or should we?
|
||||
updateActiveSigner(null, normalized);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof signerOrPubkey === "object") {
|
||||
const pubkey = normalizePubkey(signerOrPubkey.pubkey);
|
||||
if (pubkey) {
|
||||
registerSigner(pubkey, signerOrPubkey);
|
||||
// Force update of the registry for this pubkey
|
||||
registeredSigners.set(pubkey, { signer: signerOrPubkey, meta: {} });
|
||||
// emitSignerEvent("signer:connected", ...); // Skip for now to keep diff small
|
||||
}
|
||||
updateActiveSigner(signerOrPubkey, pubkey);
|
||||
return signerOrPubkey;
|
||||
|
||||
@@ -295,10 +295,10 @@ const createMockToolkit = () => ({
|
||||
encrypt: () => "mock_ciphertext",
|
||||
decrypt: () => "mock_plaintext",
|
||||
},
|
||||
getPublicKey: () => "mock_pubkey",
|
||||
getPublicKey: () => "f".repeat(64),
|
||||
generateSecretKey: () => new Uint8Array(32),
|
||||
generatePrivateKey: () => "mock_private_key",
|
||||
finalizeEvent: () => ({ id: "mock_id", sig: "mock_sig" }),
|
||||
generatePrivateKey: () => "f".repeat(64),
|
||||
finalizeEvent: () => ({ id: "f".repeat(64), sig: "f".repeat(128) }),
|
||||
verifyEvent: () => true,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
Generated
+1
-1
@@ -14,7 +14,7 @@
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"nostr-tools": "2.19.4",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"ws": "^8.19.0"
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.27.2",
|
||||
"nostr-tools": "2.19.4",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"ws": "^8.19.0"
|
||||
|
||||
@@ -458,10 +458,12 @@ describe("NIP-07 Login Permissions", () => {
|
||||
it("NIP-07 login does not wait for deferred permission grants", async () => {
|
||||
clearStoredPermissions();
|
||||
const env = setupLoginEnvironment();
|
||||
const originalEnsurePermissions = nostrClient.ensureExtensionPermissions;
|
||||
// With SignerManager refactor, we need to mock the method on the manager
|
||||
const targetObject = nostrClient.signerManager || nostrClient;
|
||||
const originalEnsurePermissions = targetObject.ensureExtensionPermissions;
|
||||
let completionPromise;
|
||||
|
||||
nostrClient.ensureExtensionPermissions = async () => {
|
||||
targetObject.ensureExtensionPermissions = async () => {
|
||||
completionPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("permission denied")), 300);
|
||||
});
|
||||
@@ -484,7 +486,7 @@ describe("NIP-07 Login Permissions", () => {
|
||||
"deferred permission grants should still reject",
|
||||
);
|
||||
} finally {
|
||||
nostrClient.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
targetObject.ensureExtensionPermissions = originalEnsurePermissions;
|
||||
env.restore();
|
||||
nostrClient.logout();
|
||||
clearStoredPermissions();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it, after } from "node:test";
|
||||
import { ensureNostrTools } from "../js/nostr/toolkit.js";
|
||||
|
||||
describe("Nostr Private Key Signer", () => {
|
||||
after(() => {
|
||||
@@ -11,31 +12,23 @@ describe("Nostr Private Key Signer", () => {
|
||||
const previousNostrTools = globalThis.NostrTools;
|
||||
const previousReady = globalThis.nostrToolsReady;
|
||||
|
||||
// Use toolkit to ensure nostr-tools is available in CI environment
|
||||
const toolkit = await import("../js/nostr/toolkit.js");
|
||||
const nostrTools = await toolkit.ensureNostrTools();
|
||||
const nostrTools = await ensureNostrTools();
|
||||
const canonicalTools = { ...nostrTools };
|
||||
|
||||
// Patch mock tools to return valid hex if needed
|
||||
try {
|
||||
if (canonicalTools.getPublicKey && canonicalTools.getPublicKey("f".repeat(64)) === "mock_pubkey") {
|
||||
canonicalTools.getPublicKey = () => "f".repeat(64);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors if real tools throw on dummy input, though "f".repeat(64) should be valid
|
||||
// Override mocks to support roundtrip test
|
||||
if (canonicalTools.nip04) {
|
||||
canonicalTools.nip04 = {
|
||||
...canonicalTools.nip04,
|
||||
encrypt: async (priv, pub, text) => `encrypted:${text}`,
|
||||
decrypt: async (priv, pub, cipher) => cipher.replace("encrypted:", "")
|
||||
};
|
||||
}
|
||||
|
||||
// Patch mock NIP-04 to support round-trip if using mock
|
||||
if (canonicalTools.nip04) {
|
||||
try {
|
||||
if ((await canonicalTools.nip04.encrypt("f".repeat(64), "f".repeat(64), "text")) === "mock_ciphertext") {
|
||||
canonicalTools.nip04.encrypt = async (priv, pub, text) => text + "_encrypted";
|
||||
canonicalTools.nip04.decrypt = async (priv, pub, cipher) => cipher.replace("_encrypted", "");
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors from real tools on dummy input
|
||||
}
|
||||
}
|
||||
const bytesToHex = (bytes) => {
|
||||
if (nostrTools.bytesToHex) return nostrTools.bytesToHex(bytes);
|
||||
if (nostrTools.utils?.bytesToHex) return nostrTools.utils.bytesToHex(bytes);
|
||||
return Buffer.from(bytes).toString('hex');
|
||||
};
|
||||
|
||||
try {
|
||||
globalThis.__BITVID_CANONICAL_NOSTR_TOOLS__ = canonicalTools;
|
||||
@@ -51,16 +44,11 @@ describe("Nostr Private Key Signer", () => {
|
||||
import("../js/nostr/client.js"),
|
||||
]);
|
||||
|
||||
// Manually implement bytesToHex since the mock/bootstrap might not expose utils
|
||||
const bytesToHex = (bytes) => {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
const privateKeyBytes = canonicalTools.generateSecretKey();
|
||||
const privateKeyHex = bytesToHex(privateKeyBytes);
|
||||
const recipientKeyBytes = canonicalTools.generateSecretKey();
|
||||
const recipientPrivateHex = bytesToHex(recipientKeyBytes);
|
||||
const recipientPubkey = canonicalTools.getPublicKey(recipientKeyBytes);
|
||||
const privateKeyBytes = nostrTools.generateSecretKey();
|
||||
const privateKeyHex = bytesToHex(privateKeyBytes);
|
||||
const recipientKeyBytes = nostrTools.generateSecretKey();
|
||||
const recipientPrivateHex = bytesToHex(recipientKeyBytes);
|
||||
const recipientPubkey = nostrTools.getPublicKey(recipientKeyBytes);
|
||||
|
||||
await nostrClient.registerPrivateKeySigner({ privateKey: privateKeyHex });
|
||||
const signer = getActiveSigner();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, describe, before, after, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { generateSecretKey, getPublicKey } from "nostr-tools";
|
||||
import { ensureNostrTools } from "../../js/nostr/toolkit.js";
|
||||
import "../test-helpers/setup-localstorage.mjs";
|
||||
|
||||
// Mock globals needed for nostr-tools or other libs
|
||||
@@ -20,8 +20,19 @@ function bytesToHex(bytes) {
|
||||
}
|
||||
|
||||
// Helper to generate 32-byte hex string
|
||||
function generateHexId() {
|
||||
return bytesToHex(generateSecretKey());
|
||||
// Note: relies on global/closure tools if available, or just random bytes
|
||||
function generateHexId(tools) {
|
||||
if (tools && typeof tools.generateSecretKey === 'function') {
|
||||
return bytesToHex(tools.generateSecretKey());
|
||||
}
|
||||
const bytes = new Uint8Array(32);
|
||||
if (globalThis.crypto && globalThis.crypto.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
// Simple fallback for mock
|
||||
for(let i=0; i<32; i++) bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return bytesToHex(bytes);
|
||||
}
|
||||
|
||||
describe("dmSignalEvents", () => {
|
||||
@@ -33,9 +44,11 @@ describe("dmSignalEvents", () => {
|
||||
let originalSessionActor;
|
||||
let signer;
|
||||
let getActiveSigner;
|
||||
let tools;
|
||||
|
||||
before(async () => {
|
||||
// Import modules
|
||||
tools = await ensureNostrTools();
|
||||
const facadeModule = await import("../../js/nostrClientFacade.js");
|
||||
const registryModule = await import("../../js/nostrClientRegistry.js");
|
||||
dmSignalEvents = await import("../../js/nostr/dmSignalEvents.js");
|
||||
@@ -44,9 +57,9 @@ describe("dmSignalEvents", () => {
|
||||
getActiveSigner = registryModule.getActiveSigner;
|
||||
|
||||
// Generate keys
|
||||
const secret = generateSecretKey();
|
||||
const secret = tools.generateSecretKey();
|
||||
const privateKey = bytesToHex(secret);
|
||||
const pubkey = getPublicKey(secret);
|
||||
const pubkey = tools.getPublicKey(secret);
|
||||
|
||||
// Register signer to ensure getActiveSigner returns something
|
||||
await nostrClient.registerPrivateKeySigner({ privateKey, pubkey });
|
||||
@@ -93,8 +106,8 @@ describe("dmSignalEvents", () => {
|
||||
});
|
||||
|
||||
test("publishDmReadReceipt should succeed", async () => {
|
||||
const eventId = generateHexId();
|
||||
const recipientPubkey = generateHexId();
|
||||
const eventId = generateHexId(tools);
|
||||
const recipientPubkey = generateHexId(tools);
|
||||
const payload = {
|
||||
eventId,
|
||||
recipientPubkey,
|
||||
@@ -123,8 +136,8 @@ describe("dmSignalEvents", () => {
|
||||
nostrClient.sessionActor = { pubkey: nostrClient.pubkey };
|
||||
|
||||
const payload = {
|
||||
eventId: generateHexId(),
|
||||
recipientPubkey: generateHexId(),
|
||||
eventId: generateHexId(tools),
|
||||
recipientPubkey: generateHexId(tools),
|
||||
messageKind: 4
|
||||
};
|
||||
|
||||
@@ -134,8 +147,8 @@ describe("dmSignalEvents", () => {
|
||||
});
|
||||
|
||||
test("publishDmTypingIndicator should succeed", async () => {
|
||||
const recipientPubkey = generateHexId();
|
||||
const conversationEventId = generateHexId();
|
||||
const recipientPubkey = generateHexId(tools);
|
||||
const conversationEventId = generateHexId(tools);
|
||||
const payload = {
|
||||
recipientPubkey,
|
||||
conversationEventId
|
||||
@@ -157,7 +170,7 @@ describe("dmSignalEvents", () => {
|
||||
const originalSignEvent = signer.signEvent;
|
||||
signer.signEvent = undefined;
|
||||
|
||||
const payload = { eventId: generateHexId(), recipientPubkey: generateHexId() };
|
||||
const payload = { eventId: generateHexId(tools), recipientPubkey: generateHexId(tools) };
|
||||
const result = await dmSignalEvents.publishDmReadReceipt(nostrClient, payload);
|
||||
|
||||
assert.strictEqual(result.ok, false);
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
import "../test-helpers/setup-localstorage.mjs";
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
// Polyfill crypto globally immediately
|
||||
const cryptoPolyfill = {
|
||||
getRandomValues: (arr) => {
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
arr[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
subtle: webcrypto.subtle,
|
||||
};
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
globalThis.crypto = cryptoPolyfill;
|
||||
} else {
|
||||
try {
|
||||
globalThis.crypto.getRandomValues(new Uint8Array(1));
|
||||
} catch (e) {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
value: cryptoPolyfill,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
finalizeEvent,
|
||||
generateSecretKey,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
nip04,
|
||||
} from "nostr-tools";
|
||||
import * as NostrToolsRef from "nostr-tools";
|
||||
|
||||
// Inject real NostrTools into global scope BEFORE modules load
|
||||
globalThis.NostrTools = NostrToolsRef;
|
||||
|
||||
const { ensureNostrTools } = await import("../../js/nostr/toolkit.js");
|
||||
|
||||
const {
|
||||
nostrClient,
|
||||
@@ -24,38 +49,41 @@ const { normalizeActorKey } = await import("../../js/nostr/watchHistory.js");
|
||||
const { VIEW_EVENT_KIND } = await import("../../js/nostr/viewEvents.js");
|
||||
const { watchHistoryService } = await import("../../js/watchHistoryService.js");
|
||||
|
||||
function finalizeWithKey(eventTemplate, secretKey, pubkeyOverride = null) {
|
||||
const tags = Array.isArray(eventTemplate?.tags)
|
||||
? eventTemplate.tags.map((tag) => Array.isArray(tag) ? [...tag] : [])
|
||||
: [];
|
||||
const base = {
|
||||
kind: Number.isFinite(eventTemplate?.kind)
|
||||
? eventTemplate.kind
|
||||
: 1,
|
||||
content: typeof eventTemplate?.content === "string"
|
||||
? eventTemplate.content
|
||||
: "",
|
||||
created_at: Number.isFinite(eventTemplate?.created_at)
|
||||
? Math.floor(eventTemplate.created_at)
|
||||
: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
pubkey:
|
||||
typeof pubkeyOverride === "string" && pubkeyOverride
|
||||
? pubkeyOverride
|
||||
: typeof eventTemplate?.pubkey === "string" && eventTemplate.pubkey
|
||||
? eventTemplate.pubkey
|
||||
: getPublicKey(secretKey),
|
||||
};
|
||||
return finalizeEvent(base, secretKey);
|
||||
}
|
||||
|
||||
test("nostr login + remote signing + publish + watch history integration", async (t) => {
|
||||
const tools = await ensureNostrTools();
|
||||
const { finalizeEvent, generateSecretKey, getEventHash, getPublicKey, nip04 } = tools;
|
||||
|
||||
function finalizeWithKey(eventTemplate, secretKey, pubkeyOverride = null) {
|
||||
const tags = Array.isArray(eventTemplate?.tags)
|
||||
? eventTemplate.tags.map((tag) => Array.isArray(tag) ? [...tag] : [])
|
||||
: [];
|
||||
const base = {
|
||||
kind: Number.isFinite(eventTemplate?.kind)
|
||||
? eventTemplate.kind
|
||||
: 1,
|
||||
content: typeof eventTemplate?.content === "string"
|
||||
? eventTemplate.content
|
||||
: "",
|
||||
created_at: Number.isFinite(eventTemplate?.created_at)
|
||||
? Math.floor(eventTemplate.created_at)
|
||||
: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
pubkey:
|
||||
typeof pubkeyOverride === "string" && pubkeyOverride
|
||||
? pubkeyOverride
|
||||
: typeof eventTemplate?.pubkey === "string" && eventTemplate.pubkey
|
||||
? eventTemplate.pubkey
|
||||
: getPublicKey(secretKey),
|
||||
};
|
||||
return finalizeEvent(base, secretKey);
|
||||
}
|
||||
|
||||
const originalWindow = globalThis.window;
|
||||
const windowRef = originalWindow || {};
|
||||
globalThis.window = windowRef;
|
||||
|
||||
if (!windowRef.crypto || !windowRef.crypto.subtle) {
|
||||
windowRef.crypto = webcrypto;
|
||||
windowRef.crypto = globalThis.crypto;
|
||||
}
|
||||
|
||||
const originalNostr = windowRef.nostr;
|
||||
@@ -81,7 +109,10 @@ test("nostr login + remote signing + publish + watch history integration", async
|
||||
);
|
||||
const originalWatchHistoryStorage = nostrClient.watchHistory?.storage;
|
||||
|
||||
const userSecret = generateSecretKey();
|
||||
const userSecret = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
userSecret[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
const userPubkey = getPublicKey(userSecret);
|
||||
|
||||
let extensionSignCount = 0;
|
||||
@@ -239,6 +270,7 @@ test("nostr login + remote signing + publish + watch history integration", async
|
||||
const mockRpcClient = Object.create(Nip46RpcClient.prototype);
|
||||
Object.assign(mockRpcClient, {
|
||||
userPubkey,
|
||||
pubkey: userPubkey,
|
||||
async signEvent(event) {
|
||||
remoteSignCount += 1;
|
||||
return finalizeWithKey(event, userSecret, userPubkey);
|
||||
@@ -263,7 +295,7 @@ test("nostr login + remote signing + publish + watch history integration", async
|
||||
|
||||
const viewPointer = {
|
||||
type: "e",
|
||||
value: "view-event-integration",
|
||||
value: "f".repeat(64),
|
||||
watchedAt: 1_700_000_123,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user