diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js index 5fcaa0fe..05787993 100644 --- a/js/ui/components/UploadModal.js +++ b/js/ui/components/UploadModal.js @@ -1259,13 +1259,70 @@ export class UploadModal { throw new Error("Please provide at least a Video URL or Magnet Link."); } - if (!hasUrl && !hasImeta && hasMagnet) { + // Auto-derive a webseed torrent from the external URL so the link keeps the + // P2P benefit (fetch + hash the remote file → magnet with ws=url). Best-effort + // and size-capped: on CORS / too-large / any error it silently degrades to + // URL-only. Only runs when the user didn't already paste their own magnet. + if (hasUrl && !hasMagnet) { + const derived = await this.deriveExternalWebseed(metadata.url); + if (derived?.magnet) { + metadata.magnet = derived.magnet; + metadata.infoHash = derived.infoHash || metadata.infoHash || ""; + metadata.ws = [metadata.url, this.inputs.ws?.value || ""]; + if (derived.torrentUrl) metadata.xs = derived.torrentUrl; + } + } + + if (!hasUrl && !hasImeta && metadata.magnet.length > 0) { if (!(await showConfirm("Magnet-only uploads require active seeding. Proceed?"))) return; } await this.publish(metadata); } + // Best-effort webseed derivation for an external URL (TODO: external-URL P2P). + // Hosts the tiny .torrent (xs=) only when storage is actually unlocked; otherwise + // returns a ws=-only magnet. Never throws — returns null so the caller publishes + // URL-only. + async deriveExternalWebseed(url) { + if (typeof this.mediaUploader?.deriveTorrentForExternalUrl !== "function") { + return null; + } + const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null; + const canHostTorrent = Boolean( + this.activeProvider && + this.activeCredentials && + pubkey && + this.storageService?.isUnlocked?.(pubkey), + ); + const previousLabel = this.submitButton?.textContent; + if (this.submitButton) { + this.submitButton.disabled = true; + this.submitButton.textContent = "Computing torrent hash…"; + } + try { + return await this.mediaUploader.deriveTorrentForExternalUrl(url, { + provider: canHostTorrent ? this.activeProvider : undefined, + credentials: canHostTorrent ? this.activeCredentials : undefined, + onProgress: ({ label }) => { + if (this.submitStatus && label) this.submitStatus.textContent = label; + }, + }); + } catch (error) { + userLogger.warn( + "[UploadModal] External URL webseed derivation skipped:", + error?.message || error, + ); + return null; + } finally { + if (this.submitButton) { + this.submitButton.disabled = false; + if (previousLabel) this.submitButton.textContent = previousLabel; + } + if (this.submitStatus) this.submitStatus.textContent = ""; + } + } + async publish(metadata) { // Normalize & Publish const { payload, errors } = normalizeVideoNotePayload(metadata); diff --git a/js/ui/components/mediaUploader.js b/js/ui/components/mediaUploader.js index 00dee287..8f701cbf 100644 --- a/js/ui/components/mediaUploader.js +++ b/js/ui/components/mediaUploader.js @@ -12,6 +12,11 @@ import { calculateTorrentInfoHash, createTorrentMetadata, } from "../../utils/torrentHash.js"; +import { + deriveExternalUrlTorrent, + buildWebseedMagnet, + DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, +} from "../../utils/externalUrlTorrent.js"; import { buildR2Key, buildPublicUrl } from "../../r2.js"; import { buildS3ObjectUrl } from "../../services/s3Service.js"; import { PROVIDERS } from "../../services/storageService.js"; @@ -307,6 +312,92 @@ export class MediaUploader { return result; } + + /** + * Derive a WebTorrent webseed for an EXTERNALLY-hosted video URL so an external + * link keeps the P2P benefit. Best-effort: fetches the remote file (CORS- + * permitting), streams it under a size cap, computes the infoHash, and builds a + * magnet with the URL as the webseed (ws=). If storage credentials are available + * it also hosts the tiny .torrent so the webseed can bootstrap P2P (xs=); + * otherwise it returns a ws=-only magnet. Any failure throws (coded) so the + * caller degrades to URL-only. + * + * @returns {Promise<{ infoHash, magnet, torrentUrl, torrentFile, name, hasValidInfoHash }>} + */ + async deriveTorrentForExternalUrl( + url, + { provider, credentials, onProgress, maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES } = {}, + ) { + const emit = (fraction, label) => { + if (typeof onProgress === "function") onProgress({ fraction, label }); + }; + + emit(0, "Fetching the file to compute its hash…"); + const derived = await deriveExternalUrlTorrent(url, { + maxBytes, + onProgress: ({ received, total }) => { + emit(total ? Math.min(1, received / total) : null, "Computing torrent hash…"); + }, + }); + + const result = { + infoHash: derived.infoHash, + magnet: derived.magnet, + torrentUrl: "", + torrentFile: derived.torrentFile, + name: derived.name, + hasValidInfoHash: Boolean(derived.infoHash), + }; + + // Host the tiny .torrent (metadata) so the webseed can actually bootstrap P2P. + // Requires storage; best-effort — a failure keeps the ws=-only magnet. + const effectiveProvider = + credentials?.provider || credentials?.meta?.provider || provider; + const npub = this.safeEncodeNpub(this.getCurrentPubkey()); + if (credentials && effectiveProvider && derived.torrentFile && npub) { + try { + const service = this.serviceFor(effectiveProvider); + const { settings, bucketEntry } = await service.prepareUpload(npub, { + credentials, + }); + const torrentKey = `${npub}/external/${derived.infoHash}.torrent`; + const torrentPublicUrl = this.publicUrlFor( + effectiveProvider, + bucketEntry.publicBaseUrl, + torrentKey, + ); + emit(null, "Hosting torrent metadata…"); + await service.uploadFile({ + file: derived.torrentFile, + bucket: bucketEntry.bucket, + key: torrentKey, + accountId: settings.accountId, + endpoint: settings.endpoint, + provider: settings.provider || effectiveProvider || "cloudflare_r2", + region: settings.region, + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + forcePathStyle: settings.forcePathStyle, + createBucketIfMissing: true, + }); + result.torrentUrl = torrentPublicUrl; + result.magnet = buildWebseedMagnet({ + infoHash: derived.infoHash, + url, + name: derived.name, + torrentUrl: torrentPublicUrl, + }); + } catch (torrentHostError) { + userLogger.warn( + "[mediaUploader] Could not host .torrent for external URL; publishing ws=-only:", + torrentHostError, + ); + } + } + + emit(1, "Ready to publish!"); + return result; + } } export { isR2Provider, normalizeInfoHash, isValidInfoHash }; diff --git a/js/utils/externalUrlTorrent.js b/js/utils/externalUrlTorrent.js new file mode 100644 index 00000000..a002b2d7 --- /dev/null +++ b/js/utils/externalUrlTorrent.js @@ -0,0 +1,167 @@ +// Derive a WebTorrent webseed magnet from an externally-hosted video URL (TODO +// #45-adjacent / external-URL P2P). Posting a plain URL skips the torrent/webseed +// benefit; here we (best-effort) fetch the remote file, compute its infoHash, and +// build a magnet with the URL as the webseed (ws=). Fully optional: any failure +// (CORS, over the size cap, network) leaves the caller to fall back to URL-only. +// +// Constraints baked in: +// - CORS: fetch()-ing the remote requires the host to allow it (same requirement +// as webseed playback), so callers must treat failure as "URL-only". +// - Whole-file download: an infoHash requires reading every byte, so we stream +// with a hard size cap and abort past it (never buffer an unbounded download). + +import { createTorrentMetadata as defaultCreateTorrentMetadata } from "./torrentHash.js"; + +export const DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES = 500 * 1024 * 1024; // 500 MB + +export function deriveNameFromUrl(url) { + try { + const parsed = new URL(url); + // A path ending in "/" has no filename. + if (parsed.pathname.endsWith("/")) { + return ""; + } + const last = parsed.pathname.split("/").filter(Boolean).pop() || ""; + const decoded = decodeURIComponent(last); + return decoded && /\S/.test(decoded) ? decoded : ""; + } catch (error) { + return ""; + } +} + +// magnet:?xt=urn:btih:&dn=&ws=[&xs=] +export function buildWebseedMagnet({ infoHash, url, name, torrentUrl } = {}) { + const hash = typeof infoHash === "string" ? infoHash.trim() : ""; + const webseed = typeof url === "string" ? url.trim() : ""; + if (!hash || !webseed) { + return ""; + } + const dn = encodeURIComponent(name || deriveNameFromUrl(webseed) || "video"); + let magnet = `magnet:?xt=urn:btih:${hash}&dn=${dn}&ws=${encodeURIComponent(webseed)}`; + if (typeof torrentUrl === "string" && torrentUrl.trim()) { + magnet += `&xs=${encodeURIComponent(torrentUrl.trim())}`; + } + return magnet; +} + +// Fetch a URL into a Blob, streaming with a hard byte cap. Throws a coded error +// on any failure so the caller can distinguish + degrade: +// fetch-failed | too-large | (network error from fetchImpl) +export async function fetchBlobWithCap( + url, + { + maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, + onProgress, + fetchImpl = typeof fetch === "function" ? fetch : null, + } = {}, +) { + if (typeof fetchImpl !== "function") { + const error = new Error("fetch is unavailable in this environment."); + error.code = "fetch-unavailable"; + throw error; + } + + const response = await fetchImpl(url); + if (!response || !response.ok) { + const error = new Error( + `Could not fetch the URL (${response?.status ?? "no response"}).`, + ); + error.code = "fetch-failed"; + error.status = response?.status; + throw error; + } + + const declaredTotal = Number(response.headers?.get?.("content-length")) || 0; + if (declaredTotal && declaredTotal > maxBytes) { + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = declaredTotal; + throw error; + } + + const contentType = response.headers?.get?.("content-type") || ""; + + // Prefer streaming so we can abort a too-large download mid-flight. + if (response.body && typeof response.body.getReader === "function") { + const reader = response.body.getReader(); + const chunks = []; + let received = 0; + for (;;) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value.byteLength; + if (received > maxBytes) { + try { + await reader.cancel(); + } catch (cancelError) { + // ignore cancel failures + } + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = declaredTotal || received; + throw error; + } + chunks.push(value); + if (typeof onProgress === "function") { + onProgress({ received, total: declaredTotal }); + } + } + return new Blob(chunks, contentType ? { type: contentType } : undefined); + } + + // No streaming body: fall back to blob() and enforce the cap after the fact. + const blob = await response.blob(); + if (blob.size > maxBytes) { + const error = new Error("The file is larger than the size cap."); + error.code = "too-large"; + error.total = blob.size; + throw error; + } + return blob; +} + +// Best-effort: fetch + hash a remote URL and return { infoHash, magnet, torrentFile, +// name }. Throws (coded) on CORS/size/network failure — the caller degrades to +// URL-only. `createMetadata` + `fileFactory` are injectable for tests. +export async function deriveExternalUrlTorrent( + url, + { + maxBytes = DEFAULT_EXTERNAL_URL_HASH_MAX_BYTES, + onProgress, + fetchImpl, + createMetadata = defaultCreateTorrentMetadata, + fileFactory, + } = {}, +) { + const trimmed = typeof url === "string" ? url.trim() : ""; + if (!trimmed) { + const error = new Error("A URL is required."); + error.code = "no-url"; + throw error; + } + + const blob = await fetchBlobWithCap(trimmed, { maxBytes, onProgress, fetchImpl }); + const name = deriveNameFromUrl(trimmed) || "video"; + const file = + typeof fileFactory === "function" + ? fileFactory(blob, name) + : new File([blob], name, blob.type ? { type: blob.type } : undefined); + + const metadata = await createMetadata(file, [trimmed]); + const infoHash = typeof metadata?.infoHash === "string" ? metadata.infoHash : ""; + if (!infoHash) { + const error = new Error("Failed to compute the torrent info hash."); + error.code = "hash-failed"; + throw error; + } + + return { + infoHash, + name, + torrentFile: metadata?.torrentFile || null, + magnet: buildWebseedMagnet({ infoHash, url: trimmed, name }), + }; +} diff --git a/tests/external-url-torrent.test.mjs b/tests/external-url-torrent.test.mjs new file mode 100644 index 00000000..41085817 --- /dev/null +++ b/tests/external-url-torrent.test.mjs @@ -0,0 +1,163 @@ +// Derive a webseed magnet from an external URL (external-URL P2P). We fetch the +// remote file (best-effort, CORS-permitting), stream it under a hard size cap, +// compute the infoHash, and build a ws= magnet. Any failure must degrade to +// URL-only (the caller catches). +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-external-url-torrent +// given: "a remote URL + an injected fetch/hash" +// when: "deriveExternalUrlTorrent / fetchBlobWithCap / buildWebseedMagnet run" +// then: "magnet carries the infoHash + ws=url; oversize/fetch failures throw coded errors" +// observable_outcomes: +// - "buildWebseedMagnet: xt/dn/ws present; xs only when torrentUrl given; empty on missing infoHash/url" +// - "deriveNameFromUrl extracts + decodes the filename" +// - "fetchBlobWithCap streams under the cap and aborts past it (code too-large)" +// - "content-length over cap short-circuits before downloading" +// - "non-ok response -> fetch-failed" +// - "deriveExternalUrlTorrent returns { infoHash, magnet(ws=url) } via injected hash" +// determinism_controls: +// - "injected fetchImpl (fake Response) + createMetadata stub + fileFactory; no network/WebTorrent" +// anti_cheat_rationale: +// prevents: ["hard-coded return value", "over-mocking internal logic"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { + buildWebseedMagnet, + deriveNameFromUrl, + fetchBlobWithCap, + deriveExternalUrlTorrent, +} from "../js/utils/externalUrlTorrent.js"; + +function streamResponse(bytes, { contentLength, contentType = "video/mp4", ok = true, status = 200, chunkSize = 4 } = {}) { + const headers = new Map(); + if (contentLength !== null) { + headers.set("content-length", String(contentLength ?? bytes.length)); + } + if (contentType) headers.set("content-type", contentType); + let offset = 0; + return { + ok, + status, + headers: { get: (k) => headers.get(k.toLowerCase()) ?? null }, + body: { + getReader() { + return { + async read() { + if (offset >= bytes.length) return { done: true, value: undefined }; + const end = Math.min(offset + chunkSize, bytes.length); + const value = bytes.slice(offset, end); + offset = end; + return { done: false, value }; + }, + async cancel() {}, + }; + }, + }, + }; +} + +test("buildWebseedMagnet: xt/dn/ws, xs only when torrentUrl is given", () => { + const m = buildWebseedMagnet({ infoHash: "abc123", url: "https://h/v.mp4", name: "v.mp4" }); + assert.match(m, /^magnet:\?xt=urn:btih:abc123&dn=v\.mp4&ws=https%3A%2F%2Fh%2Fv\.mp4$/); + const withXs = buildWebseedMagnet({ infoHash: "abc", url: "https://h/v.mp4", torrentUrl: "https://h/v.torrent" }); + assert.match(withXs, /&xs=https%3A%2F%2Fh%2Fv\.torrent$/); +}); + +test("buildWebseedMagnet: empty when infoHash or url is missing", () => { + assert.equal(buildWebseedMagnet({ url: "https://h/v.mp4" }), ""); + assert.equal(buildWebseedMagnet({ infoHash: "abc" }), ""); + assert.equal(buildWebseedMagnet({}), ""); +}); + +test("deriveNameFromUrl extracts + decodes the filename", () => { + assert.equal(deriveNameFromUrl("https://h/path/My%20Clip.mp4"), "My Clip.mp4"); + assert.equal(deriveNameFromUrl("https://h/path/"), ""); + assert.equal(deriveNameFromUrl("not a url"), ""); +}); + +test("fetchBlobWithCap streams the whole body under the cap", async () => { + const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + const progress = []; + const blob = await fetchBlobWithCap("https://h/v.mp4", { + maxBytes: 1000, + onProgress: (p) => progress.push(p.received), + fetchImpl: async () => streamResponse(bytes, { chunkSize: 4 }), + }); + assert.equal(blob.size, 10); + assert.deepEqual(progress, [4, 8, 10]); +}); + +test("fetchBlobWithCap aborts a stream that exceeds the cap (too-large)", async () => { + const bytes = new Uint8Array(20); + await assert.rejects( + () => + fetchBlobWithCap("https://h/v.mp4", { + maxBytes: 8, + // no content-length header → only the streaming guard can catch it + fetchImpl: async () => streamResponse(bytes, { contentLength: null, chunkSize: 4 }), + }), + (err) => err.code === "too-large", + ); +}); + +test("fetchBlobWithCap short-circuits on content-length over the cap (no download)", async () => { + let readerMade = false; + await assert.rejects( + () => + fetchBlobWithCap("https://h/big.mp4", { + maxBytes: 100, + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: { get: (k) => (k.toLowerCase() === "content-length" ? "999999" : null) }, + body: { getReader() { readerMade = true; return { read: async () => ({ done: true }) }; } }, + }), + }), + (err) => err.code === "too-large" && err.total === 999999, + ); + assert.equal(readerMade, false, "must not start reading the body when the size is known-too-big"); +}); + +test("fetchBlobWithCap: non-ok response -> fetch-failed", async () => { + await assert.rejects( + () => fetchBlobWithCap("https://h/404", { fetchImpl: async () => ({ ok: false, status: 403, headers: { get: () => null } }) }), + (err) => err.code === "fetch-failed" && err.status === 403, + ); +}); + +test("deriveExternalUrlTorrent returns infoHash + ws= magnet via the injected hasher", async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const seen = { fileName: null, urlList: null }; + const result = await deriveExternalUrlTorrent("https://host/clips/My%20Clip.mp4", { + maxBytes: 1000, + fetchImpl: async () => streamResponse(bytes), + fileFactory: (blob, name) => ({ blob, name }), + createMetadata: async (file, urlList) => { + seen.fileName = file.name; + seen.urlList = urlList; + return { infoHash: "deadbeef", torrentFile: new Uint8Array([9]) }; + }, + }); + assert.equal(result.infoHash, "deadbeef"); + assert.equal(seen.fileName, "My Clip.mp4", "file name derived from the URL"); + assert.deepEqual(seen.urlList, ["https://host/clips/My%20Clip.mp4"], "url passed as the webseed"); + assert.match(result.magnet, /xt=urn:btih:deadbeef/); + assert.match(result.magnet, /ws=https%3A%2F%2Fhost%2Fclips%2FMy%2520Clip\.mp4/); + assert.ok(result.torrentFile, "torrentFile returned for optional .torrent hosting"); +}); + +test("deriveExternalUrlTorrent propagates a fetch failure (caller degrades to URL-only)", async () => { + await assert.rejects( + () => + deriveExternalUrlTorrent("https://host/blocked.mp4", { + fetchImpl: async () => ({ ok: false, status: 0, headers: { get: () => null } }), + createMetadata: async () => ({ infoHash: "x" }), + }), + (err) => err.code === "fetch-failed", + ); +});