diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 1ba428f7..dbeac47a 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -870,6 +870,18 @@ class R2Service { this.updateCloudflareProgress(0); this.setCloudflareUploading(true); + // Computing the torrent info-hash (and the content-hash fallback) reads the + // ENTIRE file before the upload starts and before any progress is emitted — + // on multi-GB videos that's a long, silent pause that looks like a freeze. + // Surface a status so the user knows work is happening. + const willHashFile = Boolean(file) && !isValidInfoHash(normalizeInfoHash(infoHash)); + if (willHashFile) { + this.setCloudflareUploadStatus( + "Analyzing video (large files can take a moment)…", + "info", + ); + } + const keyIdentifier = await resolveUploadIdentifier({ infoHash, file }); const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier); const hasValidInfoHash = isValidInfoHash(normalizedInfoHash); diff --git a/js/services/s3UploadService.js b/js/services/s3UploadService.js index a00722fa..96cb3ca2 100644 --- a/js/services/s3UploadService.js +++ b/js/services/s3UploadService.js @@ -271,6 +271,18 @@ export class S3UploadService { try { await this.deps.ensureS3SdkLoaded(); + // Computing the torrent info-hash (and the content-hash fallback) reads the + // ENTIRE file before the upload starts and before any progress is emitted — + // on multi-GB videos that's a long, silent pause that looks like a freeze. + const willHashFile = + Boolean(file) && !isValidInfoHash(normalizeInfoHash(infoHash)); + if (willHashFile) { + this.setUploadStatus( + "Analyzing video (large files can take a moment)…", + "info", + ); + } + const keyIdentifier = await this._resolveUploadIdentifier({ infoHash, file, diff --git a/tests/services/s3-upload-analyzing-status.test.mjs b/tests/services/s3-upload-analyzing-status.test.mjs new file mode 100644 index 00000000..f51d3883 --- /dev/null +++ b/tests/services/s3-upload-analyzing-status.test.mjs @@ -0,0 +1,82 @@ +// Scenario (SCN-upload-analyzing-status): +// Computing the torrent info-hash (and the content-hash fallback) reads the +// ENTIRE file before the upload starts, which on large videos is a long silent +// pause that looks like a freeze (Cloudflare-upload audit #2). +// Given an upload with NO usable info-hash (so the file must be hashed), +// When uploadVideo runs, +// Then an "Analyzing…" status is emitted before the upload; and when a valid +// info-hash IS supplied (no hashing needed) that status is NOT emitted. +// +// Anti-cheat: asserts the externally observable uploadStatus events, with the +// hashing dependency mocked so the assertion reflects the control-flow decision, +// not the hash itself. + +import "../test-helpers/setup-localstorage.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { S3UploadService } from "../../js/services/s3UploadService.js"; + +function makeDeps() { + return { + ensureS3SdkLoaded: async () => {}, + makeS3Client: () => ({}), + multipartUpload: async () => {}, + buildR2Key: () => "u/npub/ns/video.mp4", + computeStorageContentHash: async () => "sha256deadbeefcafe", + buildS3ObjectUrl: () => "https://mock.example/file", + getCorsOrigins: () => ["*"], + prepareS3Connection: async (o) => ({ ...o, bucket: "b", publicBaseUrl: "https://mock.example" }), + validateS3Connection: (s) => ({ + ...s, + endpoint: "https://s3.mock", + region: "auto", + accessKeyId: "k", + secretAccessKey: "s", + bucket: "b", + forcePathStyle: false, + publicBaseUrl: "https://mock.example", + }), + userLogger: { warn() {}, error() {} }, + buildStoragePointerValue: () => "ptr", + buildStoragePrefixFromKey: () => "prefix", + getVideoNoteErrorMessage: (c) => `Error: ${c}`, + normalizeVideoNotePayload: (p) => ({ payload: p, errors: [] }), + // Returns an INVALID hash, so the service treats it as "no info-hash". + calculateTorrentInfoHash: async () => "not-a-real-hash", + }; +} + +const fakeFile = { name: "video.mp4", type: "video/mp4", size: 10, slice() { return this; } }; + +async function statusesFor({ infoHash } = {}) { + const service = new S3UploadService(makeDeps()); + const messages = []; + service.on("uploadStatus", (d) => messages.push(d.message)); + const published = await service.uploadVideo({ + npub: "npub1example", + file: fakeFile, + metadata: { title: "Test" }, + settings: { endpoint: "https://s3.mock", accessKeyId: "k", secretAccessKey: "s", bucket: "b" }, + infoHash, + publishVideoNote: async () => true, + }); + return { messages, published }; +} + +test("emits an Analyzing status when the file must be hashed (no info-hash)", async () => { + const { messages, published } = await statusesFor({ infoHash: "" }); + assert.equal(published, true, "upload should still succeed"); + assert.ok( + messages.some((m) => /analyz/i.test(m)), + `expected an 'Analyzing' status, got: ${JSON.stringify(messages)}`, + ); +}); + +test("does NOT emit Analyzing when a valid info-hash is already supplied", async () => { + const { messages } = await statusesFor({ infoHash: "a".repeat(40) }); + assert.ok( + !messages.some((m) => /analyz/i.test(m)), + `expected no 'Analyzing' status, got: ${JSON.stringify(messages)}`, + ); +});