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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 14:55:41 -04:00
1 parent bbb8dd0fd8
commit 513f75e4da
6 files changed
+233 -2

No files matched your search

+93 -1
View File
@@ -3,11 +3,13 @@
// State-free helpers extracted from r2Service.js to keep that service under the
// file-size budget. No behavior change.
import { userLogger } from "../utils/logger.js";
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}$/;
@@ -207,3 +209,93 @@ export async function verifyPublicAccess(ctx, { settings, npub }) {
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),
});
}
/**
* 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 } = {},
) {
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] listVideoStorageObjects: resolve failed:", err);
}
}
if (!settings) {
return { ok: false, reason: "no-connection", keys: [] };
}
if (
!(settings.accessKeyId || "").trim() ||
!(settings.secretAccessKey || "").trim()
) {
return { ok: false, reason: "storage-locked", keys: [] };
}
const bucket = (settings.bucket || "").trim();
if (!bucket) {
return { ok: false, reason: "missing-bucket", keys: [] };
}
let s3;
try {
const ensureSdk =
typeof ctx.ensureS3SdkLoaded === "function"
? ctx.ensureS3SdkLoaded
: ensureS3SdkLoaded;
await ensureSdk();
s3 = buildProviderClient(settings, {
makeR2Client: ctx.makeR2Client,
makeS3Client: ctx.makeS3Client,
});
} catch (err) {
devLogger.warn("[R2Service] listVideoStorageObjects: client build failed:", err);
return { ok: false, reason: "client-error", keys: [] };
}
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: [] };
}
}