mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
570 lines
17 KiB
JavaScript
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;
|