feat(s3): bring generic S3 to parity with R2 (cleanup + CORS guidance)

Two gaps where generic S3 trailed Cloudflare R2:

1. Object cleanup on delete/edit didn't work for generic S3. deleteVideoStorage
   built its client via makeR2Client, which hard-forces path-style and ignores
   the connection's forcePathStyle -- so vhost-style S3 deletes were misaddressed
   and cleanup silently failed (S3 users' deleted/edited files orphaned). It is
   now provider-aware (mirrors uploadFile): R2 -> makeR2Client (path-style),
   generic S3 -> makeS3Client honoring forcePathStyle. makeS3Client is now
   injectable for testing.

2. CORS guidance on upload failure was R2-only. Extracted isLikelyCorsError +
   a generic-S3 guidance builder into js/utils/uploadErrorHints.js (re-exported
   from r2Service for back-compat); s3UploadService now appends actionable CORS
   guidance on an opaque "Failed to fetch" too.

Generic S3 is now at functional parity with R2 for upload -> publish -> cleanup.

Tests: r2-delete-video-storage (adds generic-S3 forcePathStyle routing),
s3-upload-cors-guidance (new). Full suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 06:13:38 -04:00
1 parent a7803c810b
commit fdebf05e1a
6 files changed
+207 -35

No files matched your search

@@ -18,6 +18,8 @@ const { R2Service } = await import("../../js/services/r2Service.js");
const BASE = "https://pub.bitvid.network";
const CREDS = {
provider: "cloudflare_r2",
accountId: "acct",
accessKeyId: "AK",
secretAccessKey: "SK",
bucket: "my-bucket",
@@ -28,8 +30,16 @@ const CREDS = {
function buildService(creds, deleteImpl) {
const deleted = [];
const clients = []; // records which client builder was used (r2 vs s3) + args
const svc = new R2Service({
makeR2Client: () => ({ __mock: true }),
makeR2Client: (args) => {
clients.push({ kind: "r2", args });
return { __mock: "r2" };
},
makeS3Client: (args) => {
clients.push({ kind: "s3", args });
return { __mock: "s3" };
},
deleteObject:
deleteImpl ||
(async ({ key }) => {
@@ -37,11 +47,11 @@ function buildService(creds, deleteImpl) {
}),
});
svc.resolveConnection = async () => creds;
return { svc, deleted };
return { svc, deleted, clients };
}
test("deletes the video, its .torrent, and the thumbnail for an owned video", async () => {
const { svc, deleted } = buildService(CREDS);
test("deletes the video, its .torrent, and the thumbnail for an owned video (R2)", async () => {
const { svc, deleted, clients } = buildService(CREDS);
const res = await svc.deleteVideoStorage({
npub: "npub1owner",
videos: [
@@ -54,6 +64,33 @@ test("deletes the video, its .torrent, and the thumbnail for an owned video", as
new Set(["u/np/h/clip.mp4", "u/np/h/clip.torrent", "u/np/h/clip.thumb.jpg"]),
);
assert.equal(res.deleted.length, 3);
// R2 connection => R2 (path-style) client.
assert.equal(clients[0]?.kind, "r2");
});
test("uses a forcePathStyle-aware S3 client for a generic-S3 connection", async () => {
// No accountId, non-R2 provider, virtual-hosted-style bucket.
const s3Creds = {
provider: "s3",
accessKeyId: "AK",
secretAccessKey: "SK",
bucket: "my-bucket",
publicBaseUrl: BASE,
endpoint: "https://s3.us-east-1.amazonaws.com",
region: "us-east-1",
forcePathStyle: false,
};
const { svc, deleted, clients } = buildService(s3Creds);
const res = await svc.deleteVideoStorage({
npub: "npub1owner",
videos: [{ url: `${BASE}/u/np/h/clip.mp4` }],
});
assert.equal(res.skipped, false);
assert.ok(deleted.includes("u/np/h/clip.mp4"), "deletes the S3 object");
// Generic S3 => S3 client built with the connection's forcePathStyle (not forced).
assert.equal(clients[0]?.kind, "s3");
assert.equal(clients[0]?.args.forcePathStyle, false);
assert.equal(clients[0]?.args.endpoint, "https://s3.us-east-1.amazonaws.com");
});
test("skips entirely when storage is locked (no credentials available)", async () => {
@@ -0,0 +1,77 @@
// Scenario (SCN-s3-upload-cors-guidance):
// Parity with the R2 path — when a generic-S3 upload fails with an opaque
// browser CORS rejection ("Failed to fetch"), the user-facing status must
// include actionable CORS guidance, not a bare "Upload failed: Failed to
// fetch". Ordinary errors must NOT get the CORS hint.
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(uploadImpl) {
return {
ensureS3SdkLoaded: async () => {},
makeS3Client: () => ({}),
multipartUpload: uploadImpl,
buildR2Key: () => "u/np/ns/video.mp4",
computeStorageContentHash: async () => "sha256deadbeef",
buildS3ObjectUrl: () => "https://mock.example/file",
getCorsOrigins: () => ["*"],
prepareS3Connection: async (o) => ({ ...o }),
validateS3Connection: (s) => ({
...s,
endpoint: "https://s3.us-east-1.amazonaws.com",
region: "us-east-1",
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 fakeFile = { name: "v.mp4", type: "video/mp4", size: 10, slice() { return this; } };
async function uploadAndCaptureError(uploadImpl) {
const service = new S3UploadService(makeDeps(uploadImpl));
const errors = [];
service.on("uploadStatus", (d) => {
if (d.variant === "error") errors.push(d.message);
});
await service.uploadVideo({
npub: "npub1x",
file: fakeFile,
metadata: { title: "T" },
settings: { endpoint: "https://s3.mock", accessKeyId: "k", secretAccessKey: "s", bucket: "b" },
infoHash: "a".repeat(40),
publishVideoNote: async () => true,
});
return errors;
}
test("a CORS-like upload failure surfaces actionable CORS guidance", async () => {
const errors = await uploadAndCaptureError(async () => {
throw new TypeError("Failed to fetch");
});
const msg = errors.join(" | ");
assert.match(msg, /CORS/i, `expected CORS guidance, got: ${msg}`);
assert.match(msg, /s3\.us-east-1\.amazonaws\.com/, "names the S3 endpoint to configure");
});
test("an ordinary upload error does NOT get the CORS hint", async () => {
const errors = await uploadAndCaptureError(async () => {
throw new Error("Access Denied");
});
const msg = errors.join(" | ");
assert.ok(msg.includes("Access Denied"));
assert.doesNotMatch(msg, /CORS/i);
});