Files
bitvid/js/storage/r2-s3.js
T
thePR0M3TH3ANandClaude Opus 4.8 513f75e4da feat(my-videos): bucket object listing for reconciliation (Phase 2, step 2)
Adds the bucket-enumeration primitive the My Videos tab needs to find orphan and
missing files:
- s3 layer: ListObjectsV2Command wired into the SDK wrapper + a paginated
  listObjects({ s3, bucket, prefix, maxKeys }) helper (follows continuation
  tokens, bounded by maxKeys).
- r2Service.listVideoStorageObjects({ npub|pubkey }) — unlock-gated, lists under
  the user's own u/<npub>/ prefix; returns { ok, reason, keys }. Body lives in
  r2ServiceHelpers (with a shared buildProviderClient) so r2Service stays under
  its file-size budget; ensureS3SdkLoaded is injectable via ctx for testing.

Tests + mutation-verified: never lists while locked (storage-locked, no call),
no-connection / missing-bucket gating, and unlocked listing is scoped to the
user's own u/<npub>/ prefix (empty-prefix mutation fails). Removing the lock gate
or widening the prefix each fail a scenario.

Build + full lint green (r2Service back under limit).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-20 14:55:41 -04:00

192 lines
5.7 KiB
JavaScript

// js/storage/r2-s3.js
import {
ensureS3SdkLoaded,
getS3SdkLoadError,
isS3SdkAvailable,
makeS3Client,
requireAwsSdk,
} from "./s3-client.js";
import {
ensureBucketExists,
ensureBucketCors,
deleteObject,
listObjects,
multipartUpload,
} from "./s3-multipart.js";
import { userLogger } from "../utils/logger.js";
import { buildPublicUrl } from "../r2.js";
export async function testS3Connection(config) {
if (!config) {
throw new Error("Configuration required for testing S3 connection.");
}
await ensureS3SdkLoaded();
const {
ListBucketsCommand,
HeadBucketCommand,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} = requireAwsSdk();
const s3 = makeS3Client({
endpoint: config.endpoint,
region: config.region,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
forcePathStyle: config.forcePathStyle,
});
// If bucket is specified, we perform a rigorous Read/Write/Delete test.
if (config.bucket) {
userLogger.info(`[Storage Test] Checking accessibility of bucket "${config.bucket}"...`);
try {
await s3.send(new HeadBucketCommand({ Bucket: config.bucket }));
userLogger.info(`[Storage Test] Bucket "${config.bucket}" exists and is accessible.`);
} catch (error) {
const status = error?.$metadata?.httpStatusCode || 0;
if (status === 403) {
throw new Error(`Access denied to bucket "${config.bucket}". Check your permissions.`);
} else if (status === 404) {
throw new Error(`Bucket "${config.bucket}" does not exist.`);
}
throw error;
}
// Prepare temp file
const timestamp = Date.now();
const testKey = `bitvid-test-connection-${timestamp}.txt`;
const fileContent = `bitvid storage test ${timestamp}`;
try {
// 1. Upload
userLogger.info(`[Storage Test] Uploading temporary file "${testKey}"...`);
await s3.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: testKey,
Body: fileContent,
ContentType: "text/plain",
})
);
userLogger.info("[Storage Test] Upload successful.");
// 2. Retrieve
userLogger.info(`[Storage Test] Verifying file retrieval for "${testKey}"...`);
await s3.send(
new GetObjectCommand({
Bucket: config.bucket,
Key: testKey,
})
);
userLogger.info("[Storage Test] File retrieval successful.");
// 3. Public URL check
let warnings = [];
if (config.publicBaseUrl) {
userLogger.info(`[Storage Test] Verifying public access via ${config.publicBaseUrl}...`);
const publicUrl = buildPublicUrl(config.publicBaseUrl, testKey);
try {
const response = await fetch(publicUrl, { method: "HEAD", cache: "no-cache" });
if (response.ok) {
userLogger.info("[Storage Test] Public access confirmed.");
} else {
const msg = `[Storage Test] WARNING: Public access failed (HTTP ${response.status}). Check your Public URL and CORS settings.`;
userLogger.warn(msg);
warnings.push(msg);
}
} catch (fetchErr) {
const msg = `[Storage Test] WARNING: Public access check failed: ${fetchErr.message}. Check CORS.`;
userLogger.warn(msg);
warnings.push(msg);
}
} else {
const msg = "[Storage Test] WARNING: Public Bucket URL is missing. Uploads will fail until this is configured.";
userLogger.warn(msg);
warnings.push(msg);
}
// 4. Delete
userLogger.info(`[Storage Test] Cleaning up temporary file "${testKey}"...`);
await s3.send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: testKey,
})
);
userLogger.info("[Storage Test] Cleanup successful.");
const successMessage = `Connection verified! Successfully uploaded, retrieved, and deleted test file in "${config.bucket}".`;
return {
success: true,
message: warnings.length ? `${successMessage} ${warnings.join(" ")}` : successMessage,
};
} catch (error) {
userLogger.error("[Storage Test] Operation failed:", error);
// Attempt cleanup if upload succeeded but retrieval failed
try {
await s3.send(
new DeleteObjectCommand({
Bucket: config.bucket,
Key: testKey,
})
).catch(() => {});
} catch (cleanupErr) {
// ignore
}
throw error;
}
}
// Otherwise, we just list buckets to verify credentials.
userLogger.info("[Storage Test] No bucket specified. Listing buckets to verify credentials...");
try {
const response = await s3.send(new ListBucketsCommand({}));
const count = response.Buckets ? response.Buckets.length : 0;
userLogger.info(`[Storage Test] Connection successful. Found ${count} buckets.`);
return { success: true, message: `Connection successful. Found ${count} buckets.` };
} catch (error) {
const status = error?.$metadata?.httpStatusCode || 0;
if (status === 403) {
throw new Error("Access denied. Check your Access Key ID and Secret Access Key.");
}
throw error;
}
}
export function makeR2Client({
accountId,
accessKeyId,
secretAccessKey,
endpoint,
region,
}) {
if ((!accountId && !endpoint) || !accessKeyId || !secretAccessKey) {
throw new Error("Missing S3/R2 credentials");
}
const finalEndpoint =
endpoint || `https://${accountId}.r2.cloudflarestorage.com`;
return makeS3Client({
region: region || "auto",
endpoint: finalEndpoint,
accessKeyId,
secretAccessKey,
forcePathStyle: true,
});
}
export {
ensureBucketExists,
ensureBucketCors,
deleteObject,
listObjects,
multipartUpload,
};