fix(upload): surface thumbnail/.torrent upload failures to the user

These optional-asset uploads are non-fatal, but failures were warn-only — the
user got a silently-missing thumbnail or torrent with no signal. Both upload
services now emit a 'warning' status at the point of failure and append a note
to the final 'Published' status (downgraded to warning variant) listing what
failed. Cloudflare-upload audit #5.

Test: tests/services/s3-upload-optional-asset-failures.test.mjs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-16 20:19:27 -04:00
1 parent 04ae0ec4e6
commit 3d6ffe997b
3 files changed
+156 -3

No files matched your search

+23 -2
View File
@@ -949,6 +949,11 @@ class R2Service {
region: effectiveSettings.region,
});
// Track optional-asset failures so the user gets a visible signal instead
// of a silently-missing thumbnail/torrent (Cloudflare-upload #5).
let thumbnailFailed = false;
let torrentFailed = false;
if (thumbnailFile) {
this.setCloudflareUploadStatus("Uploading thumbnail...", "info");
const thumbExt = thumbnailFile.name.split('.').pop() || 'jpg';
@@ -970,6 +975,11 @@ class R2Service {
}
} catch (err) {
userLogger.warn("Thumbnail upload failed, continuing with video...", err);
thumbnailFailed = true;
this.setCloudflareUploadStatus(
"Thumbnail upload failed — publishing without it.",
"warning"
);
}
}
@@ -1009,6 +1019,11 @@ class R2Service {
}
} catch (err) {
userLogger.warn("Torrent metadata upload failed, continuing...", err);
torrentFailed = true;
this.setCloudflareUploadStatus(
"Torrent upload failed — publishing URL-first without the .torrent.",
"warning"
);
}
}
@@ -1107,9 +1122,15 @@ class R2Service {
publishOutcome = Boolean(published);
if (publishOutcome) {
const caveats = [];
if (thumbnailFailed) caveats.push("thumbnail");
if (torrentFailed) caveats.push("torrent");
const suffix = caveats.length
? ` (note: ${caveats.join(" & ")} upload failed)`
: "";
this.setCloudflareUploadStatus(
`Published ${publicUrl}`,
"success"
`Published ${publicUrl}${suffix}`,
caveats.length ? "warning" : "success"
);
}
}
+25 -1
View File
@@ -321,6 +321,11 @@ export class S3UploadService {
let statusMessage = `Uploading to ${normalized.bucket}…`;
this.setUploadStatus(statusMessage, "info");
// Track optional-asset failures so the user gets a visible signal instead
// of a silently-missing thumbnail/torrent (Cloudflare-upload #5).
let thumbnailFailed = false;
let torrentFailed = false;
if (thumbnailFile) {
this.setUploadStatus("Uploading thumbnail...", "info");
const thumbExt = thumbnailFile.name.split(".").pop() || "jpg";
@@ -350,6 +355,11 @@ export class S3UploadService {
"Thumbnail upload failed, continuing with video...",
err
);
thumbnailFailed = true;
this.setUploadStatus(
"Thumbnail upload failed — publishing without it.",
"warning"
);
}
}
@@ -404,6 +414,11 @@ export class S3UploadService {
"Torrent metadata upload failed, continuing...",
err
);
torrentFailed = true;
this.setUploadStatus(
"Torrent upload failed — publishing URL-first without the .torrent.",
"warning"
);
}
}
@@ -481,7 +496,16 @@ export class S3UploadService {
});
if (published) {
this.setUploadStatus(`Published ${publicUrl}`, "success");
const caveats = [];
if (thumbnailFailed) caveats.push("thumbnail");
if (torrentFailed) caveats.push("torrent");
const suffix = caveats.length
? ` (note: ${caveats.join(" & ")} upload failed)`
: "";
this.setUploadStatus(
`Published ${publicUrl}${suffix}`,
caveats.length ? "warning" : "success"
);
}
return Boolean(published);
} catch (err) {
@@ -0,0 +1,108 @@
// Scenario (SCN-upload-optional-asset-failure):
// Thumbnail and .torrent uploads are non-fatal, but their failures used to be
// swallowed (warn-only) so the user got a silently-missing asset
// (Cloudflare-upload audit #5).
// Given a video upload where the thumbnail AND torrent uploads fail,
// When uploadVideo runs and the note still publishes,
// Then the user sees a 'warning' status for each failed asset, and the final
// success status is a 'warning' that names what failed.
//
// Anti-cheat: drives the real uploadVideo control flow with the per-asset upload
// made to fail by content type; asserts the externally observable status events
// (message + variant), not internals.
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: () => ({}),
// Fail thumbnail (image/*) and torrent uploads; succeed for the video.
multipartUpload: async ({ contentType }) => {
if (
contentType &&
(contentType.startsWith("image/") || contentType === "application/x-bittorrent")
) {
throw new Error("simulated optional-asset upload failure");
}
},
buildR2Key: () => "u/npub/ns/video.mp4",
computeStorageContentHash: async () => "sha256deadbeef",
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: [] }),
calculateTorrentInfoHash: async () => "a".repeat(40),
};
}
const blob = (name, type) => ({ name, type, size: 8, slice() { return this; } });
test("failed thumbnail + torrent uploads surface as warnings and in the final status", async () => {
const service = new S3UploadService(makeDeps());
const events = [];
service.on("uploadStatus", (d) => events.push(d));
const published = await service.uploadVideo({
npub: "npub1example",
file: blob("video.mp4", "video/mp4"),
thumbnailFile: blob("thumb.jpg", "image/jpeg"),
torrentFile: blob("video.torrent", "application/x-bittorrent"),
metadata: { title: "Test" },
settings: { endpoint: "https://s3.mock", accessKeyId: "k", secretAccessKey: "s", bucket: "b" },
infoHash: "a".repeat(40),
publishVideoNote: async () => true,
});
assert.equal(published, true, "publish should still succeed despite optional-asset failures");
const warnings = events.filter((e) => e.variant === "warning").map((e) => e.message);
assert.ok(warnings.some((m) => /thumbnail/i.test(m)), `expected a thumbnail warning, got ${JSON.stringify(warnings)}`);
assert.ok(warnings.some((m) => /torrent/i.test(m)), `expected a torrent warning, got ${JSON.stringify(warnings)}`);
const final = events[events.length - 1];
assert.equal(final.variant, "warning", "final status should be a warning when assets failed");
assert.match(final.message, /Published/);
assert.match(final.message, /thumbnail/i);
assert.match(final.message, /torrent/i);
});
test("clean upload ends with a plain success status (no caveats)", async () => {
const deps = makeDeps();
deps.multipartUpload = async () => {}; // everything succeeds
const service = new S3UploadService(deps);
const events = [];
service.on("uploadStatus", (d) => events.push(d));
await service.uploadVideo({
npub: "npub1example",
file: blob("video.mp4", "video/mp4"),
thumbnailFile: blob("thumb.jpg", "image/jpeg"),
metadata: { title: "Test" },
settings: { endpoint: "https://s3.mock", accessKeyId: "k", secretAccessKey: "s", bucket: "b" },
infoHash: "a".repeat(40),
publishVideoNote: async () => true,
});
const final = events[events.length - 1];
assert.equal(final.variant, "success");
assert.doesNotMatch(final.message, /note:/i);
});