// js/storage/s3-multipart.js import { requireAwsSdk } from "./s3-client.js"; import logger from "../utils/logger.js"; const DEFAULT_CACHE_CONTROL = "public, max-age=3600"; function normalizeRegion(region) { if (!region) { return ""; } const trimmed = String(region).trim(); if (!trimmed || trimmed.toLowerCase() === "auto") { return ""; } return trimmed; } function getCreateBucketConfig(region) { const normalized = normalizeRegion(region); if (!normalized || normalized === "us-east-1") { return {}; } return { CreateBucketConfiguration: { LocationConstraint: normalized, }, }; } // Exported for tests. Upload keys are content-addressed (buildR2Key namespaces // by the file's infohash), so media at a given key never changes — a long // immutable Cache-Control is safe and makes browsers keep thumbnails/avatars/ // videos across visits instead of re-fetching them. Playlists (m3u8/mpd) stay // short-lived because live/HLS manifests DO mutate in place. export function computeCacheControl(key) { if (!key) { return DEFAULT_CACHE_CONTROL; } const lower = String(key).toLowerCase(); if (/(?:\.)(m3u8|mpd)$/.test(lower)) { return "public, max-age=60, must-revalidate"; } if ( /\.(m4s|ts|mp4|webm|mov|mkv|png|jpg|jpeg|gif|svg|webp|avif|ico|vtt|srt|mpg|mpeg|torrent)$/.test( lower ) ) { return "public, max-age=31536000, immutable"; } return DEFAULT_CACHE_CONTROL; } export async function ensureBucketExists({ s3, bucket, region } = {}) { if (!s3) { throw new Error("S3 client is required to ensure buckets"); } if (!bucket) { throw new Error("Bucket name is required to ensure buckets"); } const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk(); try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); return; } catch (error) { const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if (status !== 404 && code !== "NotFound" && code !== "NoSuchBucket") { throw error; } } try { await s3.send( new CreateBucketCommand({ Bucket: bucket, ...getCreateBucketConfig(region), }) ); } catch (error) { const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if ( status === 409 || code === "BucketAlreadyOwnedByYou" || code === "BucketAlreadyExists" ) { return; } throw error; } } export async function ensureBucketCors({ s3, bucket, origins, region } = {}) { if (!s3) { throw new Error("S3 client is required to configure CORS"); } if (!bucket) { throw new Error("Bucket name is required to configure CORS"); } const allowedOrigins = [...new Set((origins || []).filter(Boolean))]; if (allowedOrigins.length === 0) { return; } const { GetBucketCorsCommand, PutBucketCorsCommand } = requireAwsSdk(); await ensureBucketExists({ s3, bucket, region }); let existingRules; try { const response = await s3.send(new GetBucketCorsCommand({ Bucket: bucket })); existingRules = response.CORSRules || []; } catch (error) { // A denied or failed read must never turn into a destructive replacement. if (error?.name !== "NoSuchCORSConfiguration") { throw error; } existingRules = []; } const defaultRule = { AllowedHeaders: ["*"], AllowedMethods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"], ExposeHeaders: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"], MaxAgeSeconds: 3600, }; const equivalentRules = existingRules.filter((rule) => // Only explicit origins in equivalent default rules count as coverage. // Arbitrary rules (including wildcard rules) remain completely untouched. Object.keys(rule).every((key) => key === "AllowedOrigins" || key === "ID" || key in defaultRule ) && rule.MaxAgeSeconds === defaultRule.MaxAgeSeconds && ["AllowedHeaders", "AllowedMethods", "ExposeHeaders"].every((key) => Array.isArray(rule[key]) && rule[key].length === defaultRule[key].length && defaultRule[key].every((value) => rule[key].includes(value)) ) ); const uncoveredOrigins = allowedOrigins.filter((origin) => !equivalentRules.some((rule) => rule.AllowedOrigins?.includes(origin)) ); if (uncoveredOrigins.length === 0) { return; } const command = new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: [ ...existingRules, { ...defaultRule, AllowedOrigins: uncoveredOrigins }, ], }, }); await s3.send(command); } export async function deleteObject({ s3, bucket, key } = {}) { if (!s3 || !bucket || !key) { throw new Error("Missing required parameters for deleteObject"); } const { DeleteObjectCommand } = requireAwsSdk(); try { await s3.send( new DeleteObjectCommand({ Bucket: bucket, Key: key, }) ); } catch (error) { // We treat 404 (NoSuchKey) as success since the object is gone. const status = error?.$metadata?.httpStatusCode || 0; const code = error?.name || error?.Code || ""; if (status !== 404 && code !== "NoSuchKey") { throw error; } } } /** * List object keys under a prefix, following pagination. Returns plain key * strings. `maxKeys` is a safety cap so an enormous bucket can't run unbounded. */ export async function listObjects({ s3, bucket, prefix = "", maxKeys = 5000 } = {}) { if (!s3 || !bucket) { throw new Error("Missing required parameters for listObjects"); } const { ListObjectsV2Command } = requireAwsSdk(); const keys = []; let continuationToken; do { const response = await s3.send( new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || undefined, ContinuationToken: continuationToken, }) ); const contents = Array.isArray(response?.Contents) ? response.Contents : []; for (const item of contents) { if (item && typeof item.Key === "string" && item.Key) { keys.push(item.Key); } } continuationToken = response?.IsTruncated ? response.NextContinuationToken : undefined; } while (continuationToken && keys.length < maxKeys); return keys; } export async function multipartUpload({ s3, bucket, key, file, contentType, onProgress, createBucketIfMissing = false, region, } = {}) { if (!s3) { throw new Error("S3 client is required"); } if (!bucket) { throw new Error("Bucket is required"); } if (!key) { throw new Error("Object key is required"); } if (!file) { throw new Error("File is required"); } // A 0-byte file produces a multipart upload with zero parts, which S3/R2 // rejects with a cryptic CompleteMultipartUpload error. Fail fast and clearly. if (typeof file.size === "number" && file.size === 0) { throw new Error("File is empty (0 bytes). Choose a non-empty file to upload."); } const { CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, } = requireAwsSdk(); if (createBucketIfMissing) { try { await ensureBucketExists({ s3, bucket, region }); } catch (error) { logger.dev.warn( "[S3 multipart] Bucket creation check failed, continuing anyway.", error ); } } logger.dev.debug("[S3 multipart] Starting multipart upload for:", key); const createCommand = new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType || file.type || "application/octet-stream", CacheControl: computeCacheControl(key), }); const { UploadId } = await s3.send(createCommand); if (!UploadId) { throw new Error("Failed to start multipart upload"); } const MIN_PART = 5 * 1024 * 1024; const maxParts = 10000; const calculated = Math.ceil(file.size / maxParts); const partSize = Math.max(MIN_PART, calculated); const total = file.size; const parts = []; let uploadedBytes = 0; let partNumber = 1; if (typeof onProgress === "function") { onProgress(0); } try { while (uploadedBytes < total) { const start = uploadedBytes; const end = Math.min(start + partSize, total); const chunk = file.slice(start, end); const { ETag } = await s3.send( new UploadPartCommand({ Bucket: bucket, Key: key, UploadId, PartNumber: partNumber, Body: chunk, }) ); parts.push({ ETag, PartNumber: partNumber }); uploadedBytes = end; partNumber += 1; if (typeof onProgress === "function") { onProgress(uploadedBytes / total); } } await s3.send( new CompleteMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId, MultipartUpload: { Parts: parts }, }) ); } catch (error) { await s3 .send( new AbortMultipartUploadCommand({ Bucket: bucket, Key: key, UploadId, }) ) .catch(() => {}); throw error; } }