mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
417 lines
10 KiB
JavaScript
417 lines
10 KiB
JavaScript
const S3_MODULE_URL =
|
|
"https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
|
|
|
|
const DB_NAME = "bitvidSettings";
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = "kv";
|
|
const SETTINGS_KEY = "r2Settings";
|
|
const LOCALSTORAGE_FALLBACK_KEY = "bitvid:r2Settings";
|
|
|
|
let s3ModulePromise = null;
|
|
|
|
function loadS3Module() {
|
|
if (!s3ModulePromise) {
|
|
s3ModulePromise = import(S3_MODULE_URL);
|
|
}
|
|
return s3ModulePromise;
|
|
}
|
|
|
|
function isIndexedDbAvailable() {
|
|
try {
|
|
return typeof indexedDB !== "undefined";
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function openSettingsDb() {
|
|
return new Promise((resolve, reject) => {
|
|
if (!isIndexedDbAvailable()) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
db.createObjectStore(STORE_NAME);
|
|
}
|
|
};
|
|
|
|
request.onsuccess = () => {
|
|
resolve(request.result);
|
|
};
|
|
|
|
request.onerror = () => {
|
|
reject(request.error || new Error("Failed to open settings DB"));
|
|
};
|
|
});
|
|
}
|
|
|
|
function normalizeSettings(raw) {
|
|
if (!raw || typeof raw !== "object") {
|
|
return {
|
|
accountId: "",
|
|
accessKeyId: "",
|
|
secretAccessKey: "",
|
|
publicBaseUrlTemplate: "",
|
|
bucketMode: "auto",
|
|
manualBucket: "",
|
|
autoBuckets: {},
|
|
};
|
|
}
|
|
|
|
return {
|
|
accountId: String(raw.accountId || ""),
|
|
accessKeyId: String(raw.accessKeyId || ""),
|
|
secretAccessKey: String(raw.secretAccessKey || ""),
|
|
publicBaseUrlTemplate: String(raw.publicBaseUrlTemplate || ""),
|
|
bucketMode: raw.bucketMode === "manual" ? "manual" : "auto",
|
|
manualBucket: String(raw.manualBucket || ""),
|
|
autoBuckets:
|
|
raw.autoBuckets && typeof raw.autoBuckets === "object"
|
|
? { ...raw.autoBuckets }
|
|
: {},
|
|
};
|
|
}
|
|
|
|
export async function loadR2Settings() {
|
|
try {
|
|
const db = await openSettingsDb();
|
|
if (db) {
|
|
return await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, "readonly");
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.get(SETTINGS_KEY);
|
|
req.onsuccess = () => {
|
|
resolve(normalizeSettings(req.result));
|
|
};
|
|
req.onerror = () => {
|
|
reject(req.error || new Error("Failed to load settings"));
|
|
};
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to open IndexedDB for settings, falling back:", err);
|
|
}
|
|
|
|
try {
|
|
if (typeof localStorage !== "undefined") {
|
|
const raw = localStorage.getItem(LOCALSTORAGE_FALLBACK_KEY);
|
|
if (raw) {
|
|
return normalizeSettings(JSON.parse(raw));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to read fallback settings:", err);
|
|
}
|
|
|
|
return normalizeSettings(null);
|
|
}
|
|
|
|
export async function saveR2Settings(settings) {
|
|
const normalized = normalizeSettings(settings);
|
|
|
|
let db = null;
|
|
try {
|
|
db = await openSettingsDb();
|
|
} catch (err) {
|
|
console.warn("Unable to open IndexedDB, continuing with fallback:", err);
|
|
}
|
|
|
|
if (db) {
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.put(normalized, SETTINGS_KEY);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () =>
|
|
reject(req.error || new Error("Failed to save R2 settings"));
|
|
});
|
|
} else if (typeof localStorage !== "undefined") {
|
|
localStorage.setItem(
|
|
LOCALSTORAGE_FALLBACK_KEY,
|
|
JSON.stringify(normalized)
|
|
);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
export async function clearR2Settings() {
|
|
let cleared = false;
|
|
try {
|
|
const db = await openSettingsDb();
|
|
if (db) {
|
|
await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
const store = tx.objectStore(STORE_NAME);
|
|
const req = store.delete(SETTINGS_KEY);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = () => reject(req.error || new Error("Failed to clear"));
|
|
});
|
|
cleared = true;
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to clear IndexedDB settings:", err);
|
|
}
|
|
|
|
try {
|
|
if (typeof localStorage !== "undefined") {
|
|
localStorage.removeItem(LOCALSTORAGE_FALLBACK_KEY);
|
|
cleared = true;
|
|
}
|
|
} catch (err) {
|
|
console.warn("Failed to clear fallback settings:", err);
|
|
}
|
|
|
|
return cleared;
|
|
}
|
|
|
|
export function bucketForNpub(
|
|
npub,
|
|
{ prefix = "bitvid", suffix } = {}
|
|
) {
|
|
const suffixValue = suffix || Math.random().toString(36).slice(2, 8);
|
|
const npubPart = String(npub || "").toLowerCase();
|
|
const raw = `${prefix}-${npubPart}-${suffixValue}`
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, "");
|
|
const trimmed = raw.slice(0, 63).replace(/^-+|-+$/g, "");
|
|
return trimmed || `${prefix}-${Date.now()}`;
|
|
}
|
|
|
|
function ensureHeadersObject(req) {
|
|
if (!req) {
|
|
return;
|
|
}
|
|
|
|
if (req.headers && typeof req.headers.set === "function") {
|
|
req.headers.set("cf-create-bucket-if-missing", "true");
|
|
return;
|
|
}
|
|
|
|
if (req.headers && typeof req.headers === "object") {
|
|
req.headers["cf-create-bucket-if-missing"] = "true";
|
|
return;
|
|
}
|
|
|
|
if (typeof req.headers === "undefined") {
|
|
req.headers = { "cf-create-bucket-if-missing": "true" };
|
|
}
|
|
}
|
|
|
|
export async function createR2Client({
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
}) {
|
|
if (!accountId || !accessKeyId || !secretAccessKey) {
|
|
throw new Error("Missing required R2 credentials");
|
|
}
|
|
|
|
const { S3Client } = await loadS3Module();
|
|
const client = new S3Client({
|
|
region: "auto",
|
|
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
|
|
credentials: { accessKeyId, secretAccessKey },
|
|
});
|
|
|
|
client.middlewareStack.add(
|
|
(next) => async (args) => {
|
|
ensureHeadersObject(args.request);
|
|
return next(args);
|
|
},
|
|
{ step: "build", name: "r2AutoCreateBucket" }
|
|
);
|
|
|
|
return client;
|
|
}
|
|
|
|
function guessExtension(file) {
|
|
if (!file) {
|
|
return "mp4";
|
|
}
|
|
|
|
const name = typeof file.name === "string" ? file.name : "";
|
|
const dotIndex = name.lastIndexOf(".");
|
|
if (dotIndex > -1 && dotIndex < name.length - 1) {
|
|
return name.slice(dotIndex + 1).toLowerCase();
|
|
}
|
|
|
|
const type = typeof file.type === "string" ? file.type : "";
|
|
switch (type) {
|
|
case "video/webm":
|
|
return "webm";
|
|
case "application/vnd.apple.mpegurl":
|
|
return "m3u8";
|
|
case "video/mp2t":
|
|
return "ts";
|
|
case "video/quicktime":
|
|
return "mov";
|
|
case "video/x-matroska":
|
|
return "mkv";
|
|
default:
|
|
return "mp4";
|
|
}
|
|
}
|
|
|
|
export function buildR2Key(npub, file) {
|
|
const now = new Date();
|
|
const year = now.getUTCFullYear();
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
const safeNpub = String(npub || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, "");
|
|
const baseName = typeof file?.name === "string" ? file.name : "video";
|
|
const withoutExt = baseName.replace(/\.[^/.]+$/, "");
|
|
const slug = withoutExt
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
const safeSlug = slug || "video";
|
|
const ext = guessExtension(file);
|
|
return `u/${safeNpub}/${year}/${month}/${safeSlug}.${ext}`;
|
|
}
|
|
|
|
export async function uploadToR2({
|
|
s3,
|
|
bucket,
|
|
key,
|
|
file,
|
|
contentType,
|
|
onProgress,
|
|
concurrency = 4,
|
|
}) {
|
|
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");
|
|
}
|
|
|
|
const module = await loadS3Module();
|
|
const {
|
|
CreateMultipartUploadCommand,
|
|
UploadPartCommand,
|
|
CompleteMultipartUploadCommand,
|
|
AbortMultipartUploadCommand,
|
|
} = module;
|
|
|
|
const resolvedContentType =
|
|
contentType || file.type || "video/mp4";
|
|
|
|
const createCommand = new CreateMultipartUploadCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
ContentType: resolvedContentType,
|
|
CacheControl: resolvedContentType.includes("mpegurl")
|
|
? "public, max-age=30"
|
|
: "public, max-age=31536000, immutable",
|
|
});
|
|
|
|
const { UploadId } = await s3.send(createCommand);
|
|
if (!UploadId) {
|
|
throw new Error("Failed to initiate multipart upload");
|
|
}
|
|
|
|
const PART_SIZE = 8 * 1024 * 1024;
|
|
const total = file.size;
|
|
const totalParts = Math.ceil(total / PART_SIZE);
|
|
const parts = [];
|
|
let assignedPart = 0;
|
|
let uploadedBytes = 0;
|
|
const uploadErrors = [];
|
|
|
|
const workers = Array.from({ length: Math.max(1, concurrency) }, () =>
|
|
(async () => {
|
|
try {
|
|
for (;;) {
|
|
if (assignedPart >= totalParts) {
|
|
break;
|
|
}
|
|
|
|
const currentIndex = assignedPart;
|
|
assignedPart += 1;
|
|
|
|
const partNumber = currentIndex + 1;
|
|
const start = currentIndex * PART_SIZE;
|
|
const end = Math.min(start + PART_SIZE, total);
|
|
const Body = file.slice(start, end);
|
|
|
|
const command = new UploadPartCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
UploadId,
|
|
PartNumber: partNumber,
|
|
Body,
|
|
});
|
|
|
|
const { ETag } = await s3.send(command);
|
|
parts.push({ ETag, PartNumber: partNumber });
|
|
|
|
uploadedBytes += end - start;
|
|
if (typeof onProgress === "function") {
|
|
onProgress(Math.min(1, uploadedBytes / total));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
uploadErrors.push(err);
|
|
assignedPart = totalParts;
|
|
}
|
|
})()
|
|
);
|
|
|
|
try {
|
|
await Promise.all(workers);
|
|
|
|
if (uploadErrors.length > 0) {
|
|
throw uploadErrors[0];
|
|
}
|
|
|
|
parts.sort((a, b) => a.PartNumber - b.PartNumber);
|
|
|
|
const completeCommand = new CompleteMultipartUploadCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
UploadId,
|
|
MultipartUpload: { Parts: parts },
|
|
});
|
|
|
|
await s3.send(completeCommand);
|
|
} catch (err) {
|
|
try {
|
|
const abortCommand = new AbortMultipartUploadCommand({
|
|
Bucket: bucket,
|
|
Key: key,
|
|
UploadId,
|
|
});
|
|
await s3.send(abortCommand);
|
|
} catch (abortErr) {
|
|
console.warn("Failed to abort multipart upload:", abortErr);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function buildPublicUrl(bucket, key, template) {
|
|
const baseTemplate = (template || `https://${bucket}.r2.dev`).replace(
|
|
/\{bucket\}/g,
|
|
bucket
|
|
);
|
|
const sanitizedBase = baseTemplate.replace(/\/$/, "");
|
|
const encodedKey = key
|
|
.split("/")
|
|
.map((segment) => encodeURIComponent(segment))
|
|
.join("/");
|
|
return `${sanitizedBase}/${encodedKey}`;
|
|
}
|