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;