diff --git a/js/app.js b/js/app.js index e82acd1c..1761f85c 100644 --- a/js/app.js +++ b/js/app.js @@ -4,7 +4,7 @@ import { loadView } from "./viewManager.js"; import { nostrClient } from "./nostr.js"; import { torrentClient } from "./webtorrent.js"; import { isDevMode, ADMIN_SUPER_NPUB } from "./config.js"; -import { accessControl } from "./accessControl.js"; +import { accessControl, normalizeNpub } from "./accessControl.js"; import { safeDecodeMagnet } from "./magnetUtils.js"; import { extractMagnetHints, normalizeAndAugmentMagnet } from "./magnet.js"; import { deriveTorrentPlaybackConfig } from "./playbackUtils.js"; @@ -64,6 +64,9 @@ const UNSUPPORTED_BTITH_MESSAGE = "This magnet link is missing a compatible BitTorrent v1 info hash."; const FALLBACK_THUMBNAIL_SRC = "assets/jpg/video-thumbnail-fallback.jpg"; +const ADMIN_DM_IMAGE_URL = + "https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg"; +const BITVID_WEBSITE_URL = "https://bitvid.network/"; const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; const EMPTY_VIDEO_LIST_SIGNATURE = "__EMPTY__"; const PROFILE_CACHE_STORAGE_KEY = "bitvid:profileCache:v1"; @@ -4247,6 +4250,38 @@ class bitvidApp { buttonToToggle.disabled = false; buttonToToggle.removeAttribute("aria-busy"); } + + if (isAdd) { + try { + const notifyResult = await this.sendAdminListNotification({ + listType, + actorNpub, + targetNpub: target, + }); + if (!notifyResult?.ok) { + const errorMessage = this.describeNotificationError( + notifyResult?.error + ); + if (errorMessage) { + this.showError(errorMessage); + } + if (isDevMode && notifyResult?.error) { + console.warn( + "[admin] Failed to send list notification DM:", + notifyResult + ); + } + } + } catch (error) { + console.error("Failed to send list notification DM:", error); + if (isDevMode) { + console.warn( + "List update succeeded, but DM notification threw an unexpected error.", + error + ); + } + } + } } describeAdminError(code) { @@ -4272,6 +4307,79 @@ class bitvidApp { } } + describeNotificationError(code) { + switch (code) { + case "nostr-extension-missing": + return "List updated, but the DM notification failed because no Nostr extension is connected."; + case "nostr-uninitialized": + return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment."; + case "nip04-unavailable": + return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent."; + case "sign-event-unavailable": + return "List updated, but your Nostr extension could not sign the DM notification."; + case "missing-actor-pubkey": + return "List updated, but we could not determine your public key to send the DM notification."; + case "publish-failed": + return "List updated, but the DM notification could not be delivered to any relay."; + case "encryption-failed": + case "signature-failed": + return "List updated, but the DM notification failed while preparing the encrypted message."; + case "invalid-target": + case "empty-message": + return ""; + default: + return "List updated, but the DM notification could not be sent."; + } + } + + async sendAdminListNotification({ listType, actorNpub, targetNpub }) { + const normalizedTarget = normalizeNpub(targetNpub); + if (!normalizedTarget) { + return { ok: false, error: "invalid-target" }; + } + + if (!this.pubkey) { + return { ok: false, error: "missing-actor-pubkey" }; + } + + const actorHex = this.pubkey; + const fallbackActor = this.safeEncodeNpub(actorHex) || "a BitVid moderator"; + const actorDisplay = normalizeNpub(actorNpub) || fallbackActor; + const isWhitelist = listType === "whitelist"; + + const introLine = isWhitelist + ? `Great news—your npub ${normalizedTarget} has been added to the BitVid whitelist by ${actorDisplay}.` + : `We wanted to let you know that your npub ${normalizedTarget} has been placed on the BitVid blacklist by ${actorDisplay}.`; + + const statusLine = isWhitelist + ? `You now have full creator access across BitVid (${BITVID_WEBSITE_URL}).` + : `This hides your channel and prevents uploads across BitVid (${BITVID_WEBSITE_URL}) for now.`; + + const followUpLine = isWhitelist + ? "Please take a moment to review our community guidelines (https://bitvid.network/#view=community-guidelines), and reply to this DM if you have any questions." + : "Please review our community guidelines (https://bitvid.network/#view=community-guidelines). If you believe this was a mistake, you can submit an appeal at https://bitvid.network/?modal=appeals to request reinstatement, or reply to this DM with any questions."; + + const messageBody = [ + "Hi there,", + "", + introLine, + "", + statusLine, + "", + followUpLine, + "", + "— The BitVid Team", + ].join("\n"); + + const message = `![BitVid status update](${ADMIN_DM_IMAGE_URL})\n\n${messageBody}`; + + return nostrClient.sendDirectMessage( + normalizedTarget, + message, + actorHex + ); + } + async onAccessControlUpdated() { try { await this.refreshAdminPaneState(); diff --git a/js/nostr.js b/js/nostr.js index d9b2e556..2de5080a 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -52,6 +52,94 @@ function fakeDecrypt(encrypted) { return encrypted.split("").reverse().join(""); } +function decodeNpubToHex(npub) { + if (typeof npub !== "string" || !npub.trim()) { + return ""; + } + + if ( + !window?.NostrTools?.nip19 || + typeof window.NostrTools.nip19.decode !== "function" + ) { + return ""; + } + + try { + const decoded = window.NostrTools.nip19.decode(npub.trim()); + if (decoded?.type === "npub" && typeof decoded.data === "string") { + return decoded.data; + } + } catch (error) { + if (isDevMode) { + console.warn(`[nostr] Failed to decode npub: ${npub}`, error); + } + } + return ""; +} + +const DM_PUBLISH_TIMEOUT_MS = 10_000; + +function publishEventToRelay(pool, url, event) { + return new Promise((resolve) => { + let settled = false; + const finalize = (success, error = null) => { + if (settled) { + return; + } + settled = true; + resolve({ url, success, error }); + }; + + const timeoutId = setTimeout(() => { + finalize(false, new Error("publish timeout")); + }, DM_PUBLISH_TIMEOUT_MS); + + try { + const pub = pool.publish([url], event); + + if (pub && typeof pub.on === "function") { + pub.on("ok", () => { + clearTimeout(timeoutId); + finalize(true); + }); + pub.on("seen", () => { + clearTimeout(timeoutId); + finalize(true); + }); + pub.on("failed", (reason) => { + clearTimeout(timeoutId); + const err = + reason instanceof Error + ? reason + : new Error(String(reason || "publish failed")); + finalize(false, err); + }); + return; + } + + if (pub && typeof pub.then === "function") { + pub + .then(() => { + clearTimeout(timeoutId); + finalize(true); + }) + .catch((error) => { + clearTimeout(timeoutId); + finalize(false, error); + }); + return; + } + } catch (error) { + clearTimeout(timeoutId); + finalize(false, error); + return; + } + + clearTimeout(timeoutId); + finalize(true); + }); +} + const EXTENSION_MIME_MAP = { mp4: "video/mp4", m4v: "video/x-m4v", @@ -590,6 +678,108 @@ class NostrClient { if (isDevMode) console.log("User logged out."); } + async sendDirectMessage(targetNpub, message, actorPubkeyOverride = null) { + const trimmedTarget = typeof targetNpub === "string" ? targetNpub.trim() : ""; + const trimmedMessage = typeof message === "string" ? message.trim() : ""; + + if (!trimmedTarget) { + return { ok: false, error: "invalid-target" }; + } + + if (!trimmedMessage) { + return { ok: false, error: "empty-message" }; + } + + if (!this.pool) { + return { ok: false, error: "nostr-uninitialized" }; + } + + const extension = window?.nostr; + if (!extension) { + return { ok: false, error: "nostr-extension-missing" }; + } + + const nip04 = extension.nip04; + if (!nip04 || typeof nip04.encrypt !== "function") { + return { ok: false, error: "nip04-unavailable" }; + } + + if (typeof extension.signEvent !== "function") { + return { ok: false, error: "sign-event-unavailable" }; + } + + let actorHex = + typeof actorPubkeyOverride === "string" && actorPubkeyOverride.trim() + ? actorPubkeyOverride.trim() + : ""; + + if (!actorHex && typeof this.pubkey === "string") { + actorHex = this.pubkey.trim(); + } + + if (!actorHex && typeof extension.getPublicKey === "function") { + try { + actorHex = await extension.getPublicKey(); + } catch (error) { + if (isDevMode) { + console.warn( + "[nostr] Failed to fetch actor pubkey from extension:", + error + ); + } + } + } + + if (!actorHex) { + return { ok: false, error: "missing-actor-pubkey" }; + } + + const targetHex = decodeNpubToHex(trimmedTarget); + if (!targetHex) { + return { ok: false, error: "invalid-target" }; + } + + let ciphertext = ""; + try { + ciphertext = await nip04.encrypt(targetHex, trimmedMessage); + } catch (error) { + return { ok: false, error: "encryption-failed", details: error }; + } + + const event = { + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", targetHex]], + content: ciphertext, + pubkey: actorHex, + }; + + let signedEvent; + try { + signedEvent = await extension.signEvent(event); + } catch (error) { + return { ok: false, error: "signature-failed", details: error }; + } + + const relays = + Array.isArray(this.relays) && this.relays.length ? this.relays : RELAY_URLS; + + const publishResults = await Promise.all( + relays.map((url) => publishEventToRelay(this.pool, url, signedEvent)) + ); + + const success = publishResults.some((result) => result.success); + if (!success) { + return { + ok: false, + error: "publish-failed", + details: publishResults.filter((result) => !result.success), + }; + } + + return { ok: true }; + } + /** * Publish a new video using the v3 content schema. */