mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-03-16 00:14:11 +00:00
294 lines
6.7 KiB
JavaScript
294 lines
6.7 KiB
JavaScript
// js/storage/r2-mgmt.js
|
|
function computeSlugHash(input) {
|
|
const text = String(input || "");
|
|
let hash = 0;
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
hash = Math.imul(31, hash) + text.charCodeAt(i);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
export function sanitizeBucketName(npub) {
|
|
const raw = String(npub || "");
|
|
let value = raw
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|[-]+$/g, "");
|
|
|
|
if (!value) {
|
|
value = "bitvid-user";
|
|
}
|
|
|
|
if (value.length > 32) {
|
|
const prefix = value.slice(0, 20).replace(/-+$/g, "");
|
|
const hash = computeSlugHash(raw).slice(0, 8);
|
|
value = [prefix, hash].filter(Boolean).join("-");
|
|
}
|
|
|
|
value = value.replace(/-+/g, "-").replace(/^-+|[-]+$/g, "");
|
|
|
|
if (!/^[a-z0-9]/.test(value)) {
|
|
value = `bv-${value}`;
|
|
}
|
|
|
|
if (!/[a-z0-9]$/.test(value)) {
|
|
value = `${value}0`;
|
|
}
|
|
|
|
if (value.length > 63) {
|
|
value = value.slice(0, 63).replace(/-+$/g, "");
|
|
}
|
|
|
|
while (value.length < 3) {
|
|
value += "0";
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
export function deriveShortSubdomain(npub) {
|
|
const bucketSlug = sanitizeBucketName(npub);
|
|
const hash = computeSlugHash(npub).slice(0, 6);
|
|
|
|
const prefix = bucketSlug
|
|
.replace(/^bv-/, "")
|
|
.slice(0, 18)
|
|
.replace(/-+$/g, "");
|
|
|
|
let candidate = [prefix, hash].filter(Boolean).join("-");
|
|
candidate = candidate
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|[-]+$/g, "");
|
|
|
|
if (!candidate) {
|
|
candidate = "user";
|
|
}
|
|
|
|
if (candidate.length > 32) {
|
|
candidate = candidate.slice(0, 32).replace(/-+$/g, "");
|
|
}
|
|
|
|
if (!/^[a-z0-9]/.test(candidate)) {
|
|
candidate = `u${candidate}`;
|
|
}
|
|
|
|
if (!/[a-z0-9]$/.test(candidate)) {
|
|
candidate = `${candidate}0`;
|
|
}
|
|
|
|
while (candidate.length < 3) {
|
|
candidate += "0";
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
function buildError(error, fallbackMessage) {
|
|
if (error instanceof Error) {
|
|
return error;
|
|
}
|
|
const err = new Error(fallbackMessage || "Cloudflare request failed");
|
|
err.original = error;
|
|
return err;
|
|
}
|
|
|
|
// --- Cloudflare API helpers (Bearer token) ---
|
|
async function cfFetch(path, { token, method = "GET", body, headers = {} }) {
|
|
if (!token) {
|
|
throw new Error("Cloudflare API token is required");
|
|
}
|
|
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
...headers,
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
const text = await res.text();
|
|
let data = {};
|
|
if (text) {
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch (parseErr) {
|
|
if (!res.ok) {
|
|
const err = new Error(text || `Cloudflare API error ${res.status}`);
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
throw parseErr;
|
|
}
|
|
}
|
|
|
|
if (!res.ok || data?.success === false) {
|
|
const errorMessage =
|
|
data?.errors?.[0]?.message ||
|
|
data?.message ||
|
|
text ||
|
|
`Cloudflare API error ${res.status}`;
|
|
const err = new Error(errorMessage);
|
|
err.status = res.status;
|
|
err.response = data;
|
|
throw err;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// 1) Create bucket (idempotent-ish: treat 409 as success)
|
|
export async function ensureBucket({ accountId, bucket, token }) {
|
|
try {
|
|
await cfFetch(`/accounts/${accountId}/r2/buckets`, {
|
|
token,
|
|
method: "POST",
|
|
body: { name: bucket },
|
|
});
|
|
} catch (error) {
|
|
const err = buildError(error);
|
|
if (err.status === 409 || /already exists/i.test(err.message || "")) {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// 2) Set CORS so browser PUT/GETs work from your app origins
|
|
export async function putCors({ accountId, bucket, token, origins }) {
|
|
const filteredOrigins = (origins || []).filter(Boolean);
|
|
if (filteredOrigins.length === 0) {
|
|
return;
|
|
}
|
|
const rules = [
|
|
{
|
|
id: "bitvid-default",
|
|
allowed: {
|
|
origins: filteredOrigins,
|
|
methods: ["GET", "HEAD", "PUT", "POST", "DELETE", "OPTIONS"],
|
|
headers: ["*"],
|
|
},
|
|
expose_headers: ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
|
|
max_age_seconds: 3600,
|
|
},
|
|
];
|
|
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/cors`, {
|
|
token,
|
|
method: "PUT",
|
|
body: { rules },
|
|
});
|
|
}
|
|
|
|
function extractStatus(payload) {
|
|
return (
|
|
payload?.result?.status ||
|
|
payload?.result?.result?.status ||
|
|
payload?.status ||
|
|
payload?.result?.statusText ||
|
|
null
|
|
);
|
|
}
|
|
|
|
async function pollCustomDomain({
|
|
accountId,
|
|
bucket,
|
|
token,
|
|
domain,
|
|
pollInterval = 2500,
|
|
timeoutMs = 120000,
|
|
}) {
|
|
const path = `/accounts/${accountId}/r2/buckets/${bucket}/domains/custom/${encodeURIComponent(
|
|
domain
|
|
)}`;
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastPayload = null;
|
|
|
|
while (Date.now() < deadline) {
|
|
lastPayload = await cfFetch(path, { token, method: "GET" }).catch((err) => {
|
|
if (err?.status === 404) {
|
|
return null;
|
|
}
|
|
throw err;
|
|
});
|
|
|
|
if (!lastPayload) {
|
|
break;
|
|
}
|
|
|
|
const status = extractStatus(lastPayload);
|
|
if (status === "active") {
|
|
return { status, payload: lastPayload };
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
}
|
|
|
|
return {
|
|
status: extractStatus(lastPayload) || "unknown",
|
|
payload: lastPayload,
|
|
};
|
|
}
|
|
|
|
// 3) Attach custom domain to bucket (auto-provisions on Cloudflare)
|
|
export async function attachCustomDomainAndWait({
|
|
accountId,
|
|
bucket,
|
|
token,
|
|
zoneId,
|
|
domain,
|
|
pollInterval,
|
|
timeoutMs,
|
|
}) {
|
|
let shouldPoll = true;
|
|
try {
|
|
await cfFetch(`/accounts/${accountId}/r2/buckets/${bucket}/domains/custom`, {
|
|
token,
|
|
method: "POST",
|
|
body: { domain, zoneId, enabled: true },
|
|
});
|
|
} catch (error) {
|
|
const err = buildError(error);
|
|
if (err.status === 409 || /already exists/i.test(err.message || "")) {
|
|
shouldPoll = true;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
let status = "unknown";
|
|
if (shouldPoll) {
|
|
const pollResult = await pollCustomDomain({
|
|
accountId,
|
|
bucket,
|
|
token,
|
|
domain,
|
|
pollInterval,
|
|
timeoutMs,
|
|
});
|
|
status = pollResult.status;
|
|
}
|
|
|
|
const url = `https://${domain}`;
|
|
return {
|
|
url,
|
|
status,
|
|
active: status === "active",
|
|
};
|
|
}
|
|
|
|
// 4) Toggle managed r2.dev domain for the bucket
|
|
export async function setManagedDomain({ accountId, bucket, token, enabled }) {
|
|
const { result } = await cfFetch(
|
|
`/accounts/${accountId}/r2/buckets/${bucket}/domains/managed`,
|
|
{ token, method: "PUT", body: { enabled: Boolean(enabled) } }
|
|
);
|
|
const domain = result?.domain || result?.result?.domain || "";
|
|
return {
|
|
enabled: Boolean(result?.enabled ?? enabled),
|
|
url: domain ? `https://${domain}` : "",
|
|
};
|
|
}
|