Files
bitvid/js/services/s3UploadService.js
thePR0M3TH3ANandClaude Opus 4.8 6e85c2a32b [storage] fix B2 / generic-S3 uploads ("S3 endpoint is required") — prepareUpload contract
Uploading to a Backblaze B2 (or any generic-S3) bucket failed with "S3 endpoint is
required." even though the connection tested fine.

Root cause: the two upload services have different prepareUpload contracts —
r2Service.prepareUpload(npub, { credentials }) vs s3UploadService.prepareUpload(settings).
MediaUploader calls both the r2 way, so for S3/B2 the npub STRING became the settings →
validateS3Connection saw no endpoint. Compounding it, a storageService.getConnection()
result keeps bucket/endpoint/region under `.meta`, which validateS3Connection reads at
the top level. (R2 worked because its service already uses the (npub, {credentials})
contract.)

Fix:
- s3UploadService.prepareUpload now accepts (npub, { credentials }) AND a direct settings
  object (back-compat), flattening meta → top-level via flattenS3Settings.
- The storage Test now derives/passes the B2 public URL (else the prefix), so it stops
  falsely warning "Public Bucket URL is missing. Uploads will fail."

Tests: tests/services/s3-upload-prepare-contract.test.mjs (3 — meta flatten, npub ignored
when credentials given, direct-settings back-compat). Build + lint clean; upload/storage
suites 46/46.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-30 16:02:37 -04:00

570 lines
17 KiB
JavaScript

import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
import { multipartUpload } from "../storage/s3-multipart.js";
import { buildR2Key, computeStorageContentHash } from "../r2.js";
import {
buildS3ObjectUrl,
getCorsOrigins,
prepareS3Connection,
validateS3Connection,
} from "./s3Service.js";
import { userLogger } from "../utils/logger.js";
import {
buildStoragePointerValue,
buildStoragePrefixFromKey,
} from "../utils/storagePointer.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
VIDEO_NOTE_ERROR_CODES,
} from "./videoNotePayload.js";
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
import {
isLikelyCorsError,
buildGenericCorsGuidance,
} from "../utils/uploadErrorHints.js";
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
// A storageService.getConnection() result keeps bucket/region/endpoint/forcePathStyle
// under `.meta`; validateS3Connection reads them at the top level. Flatten so a saved
// connection (e.g. Backblaze B2) can be uploaded to without "S3 endpoint is required".
function flattenS3Settings(raw = {}) {
const meta = raw && typeof raw.meta === "object" && raw.meta ? raw.meta : {};
const pickBool = (a, b) =>
typeof a === "boolean" ? a : typeof b === "boolean" ? b : false;
return {
...raw,
endpoint: raw.endpoint || meta.endpoint || "",
bucket: raw.bucket || meta.bucket || "",
region: raw.region || meta.region || "auto",
accessKeyId: raw.accessKeyId || "",
secretAccessKey: raw.secretAccessKey || "",
forcePathStyle: pickBool(raw.forcePathStyle, meta.forcePathStyle),
publicBaseUrl:
raw.publicBaseUrl ||
meta.publicBaseUrl ||
raw.baseDomain ||
meta.baseDomain ||
"",
};
}
function normalizeInfoHash(value) {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isValidInfoHash(value) {
return INFO_HASH_PATTERN.test(value);
}
const defaultDeps = {
ensureS3SdkLoaded,
makeS3Client,
multipartUpload,
buildR2Key,
computeStorageContentHash,
buildS3ObjectUrl,
getCorsOrigins,
prepareS3Connection,
validateS3Connection,
userLogger,
buildStoragePointerValue,
buildStoragePrefixFromKey,
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
calculateTorrentInfoHash,
};
export class S3UploadService {
constructor(deps = {}) {
this.deps = { ...defaultDeps, ...deps };
this.listeners = new Map();
}
async _resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
const normalizedInfoHash = normalizeInfoHash(infoHash);
if (isValidInfoHash(normalizedInfoHash)) {
return normalizedInfoHash;
}
if (!file) {
return "";
}
try {
const computedHash = await this.deps.calculateTorrentInfoHash(file);
const normalizedComputed = normalizeInfoHash(computedHash);
if (isValidInfoHash(normalizedComputed)) {
return normalizedComputed;
}
} catch (err) {
this.deps.userLogger.warn(
"Failed to precompute info hash for storage key:",
err
);
}
return "";
}
on(event, handler) {
if (typeof handler !== "function") {
return () => {};
}
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const handlers = this.listeners.get(event);
handlers.add(handler);
return () => {
handlers.delete(handler);
if (!handlers.size) {
this.listeners.delete(event);
}
};
}
emit(event, detail) {
const handlers = this.listeners.get(event);
if (!handlers) {
return;
}
for (const handler of handlers) {
try {
handler(detail);
} catch (err) {
this.deps.userLogger.error(
"[s3UploadService] Listener error for",
event,
err
);
}
}
}
normalizeStatusVariant(variant) {
if (STATUS_VARIANTS.has(variant)) {
return variant;
}
return "info";
}
setUploadStatus(message = "", variant = "info") {
this.emit("uploadStatus", {
message: message || "",
variant: this.normalizeStatusVariant(variant),
});
}
setUploading(isUploading) {
this.emit("uploadStateChange", { isUploading: Boolean(isUploading) });
}
updateProgress(fraction) {
this.emit("uploadProgress", { fraction });
}
async verifyConnection({
settings = {},
createBucketIfMissing = true,
origins = null,
} = {}) {
const normalized = this.deps.validateS3Connection(settings);
const allowedOrigins = Array.isArray(origins)
? origins
: this.deps.getCorsOrigins();
const prepared = await this.deps.prepareS3Connection({
...normalized,
origins: allowedOrigins,
createBucketIfMissing,
});
return prepared;
}
async prepareUpload(settingsOrNpub = {}, options = {}) {
const { createBucketIfMissing = true, credentials } = options || {};
// Two call styles must both work:
// - direct: prepareUpload(settings, { createBucketIfMissing })
// - mediaUploader: prepareUpload(npub, { credentials }) ← like r2Service
// When credentials come from storageService.getConnection, the bucket/region/
// endpoint live under `.meta`, so flatten them to the top level that
// validateS3Connection reads. (npub is ignored when credentials are supplied.)
const raw = credentials || settingsOrNpub || {};
const settings = flattenS3Settings(raw);
const prepared = await this.verifyConnection({
settings,
createBucketIfMissing,
});
return {
settings: prepared,
bucketEntry: {
bucket: prepared.bucket,
publicBaseUrl: prepared.publicBaseUrl,
},
};
}
async uploadFile({
file,
endpoint,
region,
accessKeyId,
secretAccessKey,
forcePathStyle,
bucket,
key,
onProgress,
createBucketIfMissing = true,
} = {}) {
if (
!file ||
!bucket ||
!key ||
!endpoint ||
!accessKeyId ||
!secretAccessKey
) {
throw new Error("Missing required parameters for uploadFile");
}
await this.deps.ensureS3SdkLoaded();
const s3 = this.deps.makeS3Client({
endpoint,
region,
accessKeyId,
secretAccessKey,
forcePathStyle: Boolean(forcePathStyle),
});
await this.deps.multipartUpload({
s3,
bucket,
key,
file,
contentType: file.type || "application/octet-stream",
createBucketIfMissing,
region,
onProgress: (fraction) => {
if (typeof onProgress === "function") {
onProgress(fraction);
}
},
});
return { bucket, key };
}
async uploadVideo({
npub = "",
file = null,
thumbnailFile = null,
torrentFile = null,
metadata = {},
infoHash = "",
settings = {},
createBucketIfMissing = true,
publishVideoNote,
onReset,
forcedVideoKey = "",
forcedVideoUrl = "",
forcedTorrentKey = "",
forcedTorrentUrl = "",
} = {}) {
if (!npub) {
this.setUploadStatus("Unable to encode npub.", "error");
return false;
}
const rawTitleCandidate =
metadata && typeof metadata === "object" ? metadata.title : metadata;
const title =
typeof rawTitleCandidate === "string"
? rawTitleCandidate.trim()
: String(rawTitleCandidate ?? "").trim();
if (!title) {
this.setUploadStatus(
this.deps.getVideoNoteErrorMessage(VIDEO_NOTE_ERROR_CODES.MISSING_TITLE),
"error"
);
return false;
}
if (!file) {
this.setUploadStatus("Select a video or HLS file to upload.", "error");
return false;
}
let normalized = null;
try {
normalized = this.deps.validateS3Connection(settings);
} catch (err) {
this.setUploadStatus(err?.message || "Invalid S3 settings.", "error");
return false;
}
this.setUploading(true);
this.updateProgress(0);
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,
});
const normalizedInfoHash = normalizeInfoHash(infoHash || keyIdentifier);
const hasValidInfoHash = isValidInfoHash(normalizedInfoHash);
// When no info-hash is available, derive a content-based namespace so two
// distinct URL-first uploads that share a filename can't overwrite each
// other. Kept separate from keyIdentifier so magnet generation still keys
// off the real info-hash only.
const storageIdentifier =
keyIdentifier ||
(file ? await this.deps.computeStorageContentHash(file) : "");
const s3 = this.deps.makeS3Client({
endpoint: normalized.endpoint,
region: normalized.region,
accessKeyId: normalized.accessKeyId,
secretAccessKey: normalized.secretAccessKey,
forcePathStyle: Boolean(normalized.forcePathStyle),
});
const key =
forcedVideoKey ||
this.deps.buildR2Key(npub, file, storageIdentifier);
const publicUrl =
forcedVideoUrl ||
this.deps.buildS3ObjectUrl({
publicBaseUrl: normalized.publicBaseUrl,
endpoint: normalized.endpoint,
bucket: normalized.bucket,
key,
forcePathStyle: normalized.forcePathStyle,
});
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";
const thumbKey = key.replace(/\.[^/.]+$/, "") + `.thumb.${thumbExt}`;
try {
await this.deps.multipartUpload({
s3,
bucket: normalized.bucket,
key: thumbKey,
file: thumbnailFile,
contentType: thumbnailFile.type || "image/jpeg",
createBucketIfMissing,
region: normalized.region,
});
const thumbUrl = this.deps.buildS3ObjectUrl({
publicBaseUrl: normalized.publicBaseUrl,
endpoint: normalized.endpoint,
bucket: normalized.bucket,
key: thumbKey,
forcePathStyle: normalized.forcePathStyle,
});
if (typeof metadata === "object") {
metadata.thumbnail = thumbUrl;
}
} catch (err) {
this.deps.userLogger.warn(
"Thumbnail upload failed, continuing with video...",
err
);
thumbnailFailed = true;
this.setUploadStatus(
"Thumbnail upload failed — publishing without it.",
"warning"
);
}
}
this.setUploadStatus(statusMessage, "info");
await this.deps.multipartUpload({
s3,
bucket: normalized.bucket,
key,
file,
contentType: file.type || "application/octet-stream",
createBucketIfMissing,
region: normalized.region,
onProgress: (fraction) => {
this.updateProgress(fraction);
},
});
let torrentUrl = forcedTorrentUrl || "";
if (torrentFile) {
this.setUploadStatus("Uploading torrent metadata...", "info");
const torrentKey =
forcedTorrentKey ||
(() => {
const baseKey = key.replace(/\.[^/.]+$/, "");
if (baseKey && baseKey !== key) {
return `${baseKey}.torrent`;
}
return `${key}.torrent`;
})();
try {
await this.deps.multipartUpload({
s3,
bucket: normalized.bucket,
key: torrentKey,
file: torrentFile,
contentType: "application/x-bittorrent",
createBucketIfMissing,
region: normalized.region,
});
if (!torrentUrl) {
torrentUrl = this.deps.buildS3ObjectUrl({
publicBaseUrl: normalized.publicBaseUrl,
endpoint: normalized.endpoint,
bucket: normalized.bucket,
key: torrentKey,
forcePathStyle: normalized.forcePathStyle,
});
}
} catch (err) {
this.deps.userLogger.warn(
"Torrent metadata upload failed, continuing...",
err
);
torrentFailed = true;
this.setUploadStatus(
"Torrent upload failed — publishing URL-first without the .torrent.",
"warning"
);
}
}
if (typeof publishVideoNote !== "function") {
this.deps.userLogger.warn(
"publishVideoNote handler missing; skipping publish step."
);
return false;
}
let generatedMagnet = "";
let generatedWs = "";
if (hasValidInfoHash) {
const encodedDn = encodeURIComponent(file.name);
const encodedWs = encodeURIComponent(publicUrl);
let magnet = `magnet:?xt=urn:btih:${normalizedInfoHash}&dn=${encodedDn}&ws=${encodedWs}`;
if (torrentUrl) {
const encodedXs = encodeURIComponent(torrentUrl);
magnet += `&xs=${encodedXs}`;
}
generatedMagnet = magnet;
generatedWs = publicUrl;
} else {
if (infoHash) {
this.deps.userLogger.warn(
"Invalid info hash provided. Skipping magnet generation.",
infoHash
);
}
this.setUploadStatus(
"Info hash missing or invalid. Publishing URL-first without WebTorrent fallback.",
"warning"
);
}
const storagePrefix = this.deps.buildStoragePrefixFromKey({
publicBaseUrl: normalized.publicBaseUrl,
key,
});
const storagePointer = this.deps.buildStoragePointerValue({
provider: "s3",
prefix: storagePrefix,
});
const rawVideoPayload = {
title,
url: publicUrl,
magnet: generatedMagnet || (metadata?.magnet ?? ""),
thumbnail: metadata?.thumbnail ?? "",
description: metadata?.description ?? "",
storagePointer,
ws: generatedWs || (metadata?.ws ?? ""),
xs: torrentUrl || (metadata?.xs ?? ""),
infoHash: hasValidInfoHash ? normalizedInfoHash : "",
enableComments: metadata?.enableComments,
isNsfw: metadata?.isNsfw,
isForKids: metadata?.isForKids,
};
const { payload, errors } =
this.deps.normalizeVideoNotePayload(rawVideoPayload);
if (errors.length) {
const message = this.deps.getVideoNoteErrorMessage(errors[0]);
this.setUploadStatus(message, "error");
return false;
}
const published = await publishVideoNote(payload, {
onSuccess: () => {
if (typeof onReset === "function") {
onReset();
}
},
});
if (published) {
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) {
this.deps.userLogger.error("S3 upload failed:", err);
let message = err?.message
? `Upload failed: ${err.message}`
: "Upload failed.";
// A CORS rejection during the browser PUT/POST is an opaque "Failed to
// fetch" — attach actionable guidance (parity with the R2 path).
if (isLikelyCorsError(err)) {
message += ` ${buildGenericCorsGuidance({ endpoint: normalized?.endpoint })}`;
}
this.setUploadStatus(message, "error");
return false;
} finally {
this.setUploading(false);
this.updateProgress(Number.NaN);
}
}
}
const s3UploadService = new S3UploadService();
export default s3UploadService;