mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
The bucket listing returns raw keys, but deleteVideoStorage only works from note
objects — so orphan cleanup needs a key-based delete. Adds
r2Service.deleteStorageKeys({ keys, npub|pubkey }) (unlock-gated; deletes exactly
the given keys, records per-key failures without aborting the batch). Extracts a
shared resolveStorageClient (unlock-gated connection+client resolution) now used
by both listVideoStorageObjects and deleteStorageKeys, removing the duplication.
Tests + mutation-verified: never deletes while locked, empty-list no-op, deletes
exactly the given keys, per-key failure isolation. The shared lock gate is
mutation-verified across both list and delete paths.
Build + full lint green (r2Service under its file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
// js/services/r2ServiceHelpers.js
|
|
//
|
|
// State-free helpers extracted from r2Service.js to keep that service under the
|
|
// file-size budget. No behavior change.
|
|
|
|
import { userLogger, devLogger } from "../utils/logger.js";
|
|
import { calculateTorrentInfoHash } from "../utils/torrentHash.js";
|
|
import { buildPublicUrl } from "../r2.js";
|
|
import { sanitizeBucketName } from "../storage/r2-mgmt.js";
|
|
import { truncateMiddle } from "../utils/formatters.js";
|
|
import { safeEncodeNpub } from "../utils/nostrHelpers.js";
|
|
import { ensureS3SdkLoaded } from "../storage/s3-client.js";
|
|
|
|
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
|
|
|
export function normalizeInfoHash(value) {
|
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
}
|
|
|
|
export function isValidInfoHash(value) {
|
|
return INFO_HASH_PATTERN.test(value);
|
|
}
|
|
|
|
export async function resolveUploadIdentifier({ infoHash = "", file = null } = {}) {
|
|
const normalizedInfoHash = normalizeInfoHash(infoHash);
|
|
if (isValidInfoHash(normalizedInfoHash)) {
|
|
return normalizedInfoHash;
|
|
}
|
|
if (!file) {
|
|
return "";
|
|
}
|
|
try {
|
|
const computedHash = await calculateTorrentInfoHash(file);
|
|
const normalizedComputed = normalizeInfoHash(computedHash);
|
|
if (isValidInfoHash(normalizedComputed)) {
|
|
return normalizedComputed;
|
|
}
|
|
} catch (err) {
|
|
userLogger.warn("Failed to precompute info hash for storage key:", err);
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function buildCorsGuidance({ accountId } = {}) {
|
|
const origin =
|
|
typeof window !== "undefined" && window.location
|
|
? window.location.origin
|
|
: "";
|
|
const originLabel = origin && origin !== "null" ? origin : "<your-app-origin>";
|
|
const endpoint = accountId
|
|
? `https://${accountId}.r2.cloudflarestorage.com`
|
|
: "https://<account>.r2.cloudflarestorage.com";
|
|
|
|
return [
|
|
"This is likely a CORS issue.",
|
|
`Configure CORS on the R2 S3 API endpoint (${endpoint}) — not just the public domain.`,
|
|
`Add AllowedOrigins: ${originLabel} (and any other origins),`,
|
|
"AllowedMethods: GET, HEAD, PUT, POST, DELETE, OPTIONS,",
|
|
"and AllowedHeaders: *.",
|
|
].join(" ");
|
|
}
|
|
|
|
export function createDefaultSettings() {
|
|
return {
|
|
accountId: "",
|
|
accessKeyId: "",
|
|
secretAccessKey: "",
|
|
baseDomain: "", // Now interpreted as the public URL base
|
|
buckets: {},
|
|
};
|
|
}
|
|
|
|
export function safeDecodeNpub(npub) {
|
|
if (typeof npub !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = npub.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
if (!trimmed.startsWith("npub1")) {
|
|
return null;
|
|
}
|
|
try {
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
window.NostrTools &&
|
|
window.NostrTools.nip19
|
|
) {
|
|
const { type, data } = window.NostrTools.nip19.decode(trimmed);
|
|
if (type === "npub") {
|
|
return data;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Ignore decode errors
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Uploads a tiny verification object, fetches it back over the public URL, and
|
|
// cleans up — proving the bucket exists, CORS is configured, and the public URL
|
|
// base is correct. Takes the R2Service instance (`ctx`) for its S3 operations.
|
|
export async function verifyPublicAccess(ctx, { settings, npub }) {
|
|
if (!settings || !npub) {
|
|
return { success: false, error: "Missing settings or npub." };
|
|
}
|
|
|
|
const { accountId, accessKeyId, secretAccessKey, baseDomain } = settings;
|
|
if (!accountId || !accessKeyId || !secretAccessKey || !baseDomain) {
|
|
return { success: false, error: "Incomplete credentials or missing public URL." };
|
|
}
|
|
|
|
const bucketName =
|
|
settings.bucket || settings.meta?.bucket || sanitizeBucketName(npub);
|
|
const verifyKey = `.verify-${Date.now()}-${Math.random().toString(36).substring(7)}.txt`;
|
|
const verifyContent = "bitvid-verification";
|
|
const publicUrl = buildPublicUrl(baseDomain, verifyKey);
|
|
|
|
userLogger.info(
|
|
`[R2] Verifying access for Bucket: '${bucketName}' in Account: '${truncateMiddle(accountId, 6)}'`
|
|
);
|
|
|
|
try {
|
|
// 1. Initialize S3
|
|
const s3 = ctx.makeR2Client({
|
|
accountId,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
endpoint: settings.endpoint,
|
|
region: settings.region,
|
|
});
|
|
|
|
// 2. Ensure bucket (best effort)
|
|
try {
|
|
await ctx.ensureBucketExists({
|
|
s3,
|
|
bucket: bucketName,
|
|
region: settings.region,
|
|
});
|
|
} catch (setupErr) {
|
|
userLogger.warn("Bucket creation/check warning during verification:", setupErr);
|
|
// Continue, assuming bucket might already exist and be configured
|
|
}
|
|
|
|
// 3. Ensure CORS (best effort)
|
|
try {
|
|
const corsOrigins = ctx.getCorsOrigins();
|
|
if (corsOrigins.length > 0) {
|
|
await ctx.ensureBucketCors({
|
|
s3,
|
|
bucket: bucketName,
|
|
origins: corsOrigins,
|
|
region: settings.region,
|
|
});
|
|
}
|
|
} catch (corsErr) {
|
|
userLogger.warn("CORS setup warning during verification:", corsErr);
|
|
}
|
|
|
|
// 4. Upload Test File
|
|
const file = new File([verifyContent], "verify.txt", { type: "text/plain" });
|
|
await ctx.multipartUpload({
|
|
s3,
|
|
bucket: bucketName,
|
|
key: verifyKey,
|
|
file,
|
|
contentType: "text/plain",
|
|
createBucketIfMissing: true,
|
|
region: settings.region,
|
|
});
|
|
|
|
// 4. Verify Public Access (Fetch)
|
|
// Wait a moment for propagation (R2 is usually instant-ish but helpful to wait)
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
|
|
const response = await fetch(publicUrl, { method: "GET", cache: "no-cache" });
|
|
|
|
if (!response.ok) {
|
|
// Cleanup attempt
|
|
try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
|
|
|
|
if (response.status === 404) {
|
|
return { success: false, error: "File not found. Check your Public Bucket URL." };
|
|
}
|
|
return { success: false, error: `Public access failed (HTTP ${response.status}). Is the bucket public?` };
|
|
}
|
|
|
|
const text = await response.text();
|
|
|
|
// Cleanup
|
|
try { await ctx.deleteObject({ s3, bucket: bucketName, key: verifyKey }); } catch (e) {}
|
|
|
|
if (text.trim() !== verifyContent) {
|
|
return { success: false, error: "Content mismatch. URL might be pointing elsewhere." };
|
|
}
|
|
|
|
return { success: true };
|
|
|
|
} catch (err) {
|
|
userLogger.error("Verification failed:", err);
|
|
let errorMessage = err.message || "Unknown error during verification.";
|
|
if (
|
|
errorMessage.includes("Failed to fetch") ||
|
|
errorMessage.includes("NetworkError")
|
|
) {
|
|
errorMessage += ` ${buildCorsGuidance({ accountId })} Also verify that the Bucket Name exists and your API Token has 'Object Read & Write' permissions.`;
|
|
}
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a provider-aware S3 client from resolved connection settings.
|
|
* Cloudflare R2 always uses path-style (via makeR2Client); a generic S3 bucket
|
|
* honors the connection's forcePathStyle. Shared by storage list/delete paths.
|
|
*/
|
|
export function buildProviderClient(settings, { makeR2Client, makeS3Client } = {}) {
|
|
const accessKeyId = (settings?.accessKeyId || "").trim();
|
|
const secretAccessKey = (settings?.secretAccessKey || "").trim();
|
|
const useR2 =
|
|
settings?.provider === "cloudflare_r2" || Boolean(settings?.accountId);
|
|
if (useR2) {
|
|
return makeR2Client({
|
|
accountId: settings.accountId,
|
|
endpoint: settings.endpoint,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
region: settings.region,
|
|
});
|
|
}
|
|
return makeS3Client({
|
|
endpoint: settings.endpoint,
|
|
region: settings.region,
|
|
accessKeyId,
|
|
secretAccessKey,
|
|
forcePathStyle: Boolean(settings?.forcePathStyle),
|
|
});
|
|
}
|
|
|
|
// Shared unlock-gated connection+client resolution for the storage-maintenance
|
|
// helpers. Returns { settings, s3, bucket } on success or { reason } on failure.
|
|
async function resolveStorageClient(ctx, { npub, pubkey, credentials }) {
|
|
let resolvedNpub = (npub || "").trim();
|
|
if (!resolvedNpub && pubkey) {
|
|
resolvedNpub = safeEncodeNpub(pubkey) || "";
|
|
}
|
|
let settings = credentials;
|
|
if (!settings && resolvedNpub) {
|
|
try {
|
|
settings = await ctx.resolveConnection(resolvedNpub);
|
|
} catch (err) {
|
|
devLogger.warn("[R2Service] storage client resolve failed:", err);
|
|
}
|
|
}
|
|
if (!settings) {
|
|
return { reason: "no-connection" };
|
|
}
|
|
if (
|
|
!(settings.accessKeyId || "").trim() ||
|
|
!(settings.secretAccessKey || "").trim()
|
|
) {
|
|
return { reason: "storage-locked" };
|
|
}
|
|
const bucket = (settings.bucket || "").trim();
|
|
if (!bucket) {
|
|
return { reason: "missing-bucket" };
|
|
}
|
|
try {
|
|
const ensureSdk =
|
|
typeof ctx.ensureS3SdkLoaded === "function"
|
|
? ctx.ensureS3SdkLoaded
|
|
: ensureS3SdkLoaded;
|
|
await ensureSdk();
|
|
const s3 = buildProviderClient(settings, {
|
|
makeR2Client: ctx.makeR2Client,
|
|
makeS3Client: ctx.makeS3Client,
|
|
});
|
|
return { settings, s3, bucket, resolvedNpub };
|
|
} catch (err) {
|
|
devLogger.warn("[R2Service] storage client build failed:", err);
|
|
return { reason: "client-error" };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete specific bucket object keys (orphan cleanup from the My Videos tab).
|
|
* Unlock-gated; returns { ok, deleted, failed, reason }.
|
|
*/
|
|
export async function deleteStorageKeys(ctx, { keys = [], npub = "", pubkey = "", credentials = null } = {}) {
|
|
const list = (Array.isArray(keys) ? keys : []).filter(
|
|
(k) => typeof k === "string" && k.trim(),
|
|
);
|
|
if (!list.length) {
|
|
return { ok: false, reason: "no-keys", deleted: [], failed: [] };
|
|
}
|
|
const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials });
|
|
if (resolved.reason) {
|
|
return { ok: false, reason: resolved.reason, deleted: [], failed: [] };
|
|
}
|
|
const { s3, bucket } = resolved;
|
|
const deleted = [];
|
|
const failed = [];
|
|
for (const key of list) {
|
|
try {
|
|
await ctx.deleteObject({ s3, bucket, key });
|
|
deleted.push(key);
|
|
} catch (err) {
|
|
failed.push({ key, error: err?.message || String(err) });
|
|
}
|
|
}
|
|
return { ok: true, deleted, failed, reason: "" };
|
|
}
|
|
|
|
/**
|
|
* List the object keys under a user's storage prefix (u/<npub>/). `ctx` is the
|
|
* R2Service instance (resolveConnection / makeR2Client / makeS3Client /
|
|
* listObjects). Unlock-gated; returns { ok, reason, keys }.
|
|
*/
|
|
export async function listVideoStorageObjects(
|
|
ctx,
|
|
{ npub = "", pubkey = "", credentials = null } = {},
|
|
) {
|
|
const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials });
|
|
if (resolved.reason) {
|
|
return { ok: false, reason: resolved.reason, keys: [] };
|
|
}
|
|
const { s3, bucket, resolvedNpub } = resolved;
|
|
try {
|
|
const keys = await ctx.listObjects({
|
|
s3,
|
|
bucket,
|
|
prefix: resolvedNpub ? `u/${resolvedNpub}/` : "",
|
|
});
|
|
return { ok: true, keys };
|
|
} catch (err) {
|
|
devLogger.warn("[R2Service] listVideoStorageObjects: list failed:", err);
|
|
return { ok: false, reason: "list-error", keys: [] };
|
|
}
|
|
}
|