[upload] Derive a webseed torrent for external-URL posts (P2P for external links)

Posting an external URL used to skip the torrent/webseed benefit entirely. Now the
upload modal's external tab auto-derives a WebTorrent webseed from the URL at
publish time: fetch the remote file (CORS-permitting), stream it under a hard size
cap, compute the infoHash, and publish a magnet with the URL as the webseed (ws=).
Best-effort — CORS failure / over the cap / any error silently degrades to
URL-only. Only runs when the user didn't paste their own magnet.

- js/utils/externalUrlTorrent.js: fetchBlobWithCap (streams with a 500 MB cap,
  aborts past it, short-circuits on content-length), buildWebseedMagnet, and
  deriveExternalUrlTorrent (reuses createTorrentMetadata). Pure + injectable → unit
  tested without network/WebTorrent.
- mediaUploader.deriveTorrentForExternalUrl: derives the ws= magnet and, when
  storage is unlocked, also hosts the tiny .torrent so the webseed can actually
  bootstrap P2P (xs=) — best-effort, falls back to ws=-only.
- UploadModal.handleExternalFlow calls it; deriveExternalWebseed only hosts the
  .torrent when storage is unlocked, shows progress on the submit button, and
  never throws.

Tests: tests/external-url-torrent.test.mjs (9 — magnet assembly, name parsing,
streamed size-cap enforcement + short-circuit, fetch-failed, derive-with-injected-
hash, failure propagation). Lint + build green.

Next: the same for editing a URL in the Edit modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-02 09:08:32 -04:00
1 parent a20388c5d6
commit cf6a830183
4 files changed
+479 -1

No files matched your search

+58 -1
View File
@@ -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);
+91
View File
@@ -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 };
+167
View File
@@ -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:<infoHash>&dn=<name>&ws=<url>[&xs=<torrentUrl>]
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 }),
};
}
+163
View File
@@ -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",
);
});