mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-28 00:12:25 +00:00
345 lines
9.0 KiB
JavaScript
345 lines
9.0 KiB
JavaScript
// 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;
|
|
}
|
|
}
|