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

+13
View File
@@ -35,6 +35,7 @@ import {
ensureBucketCors,
ensureBucketExists,
deleteObject,
listObjects,
} from "../storage/s3-multipart.js";
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
import { truncateMiddle } from "../utils/formatters.js";
@@ -60,6 +61,7 @@ import {
createDefaultSettings,
safeDecodeNpub,
verifyPublicAccess as verifyPublicAccessHelper,
listVideoStorageObjects as listVideoStorageObjectsHelper,
} from "./r2ServiceHelpers.js";
const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]);
@@ -76,6 +78,7 @@ export class R2Service {
ensureBucketExists: ensureBucketExistsOverride,
ensureBucketCors: ensureBucketCorsOverride,
deleteObject: deleteObjectOverride,
listObjects: listObjectsOverride,
} = {}) {
this.listeners = new Map();
this.cloudflareSettings = null;
@@ -85,6 +88,7 @@ export class R2Service {
this.ensureBucketExists = ensureBucketExistsOverride || ensureBucketExists;
this.ensureBucketCors = ensureBucketCorsOverride || ensureBucketCors;
this.deleteObject = deleteObjectOverride || deleteObject;
this.listObjects = listObjectsOverride || listObjects;
}
/**
@@ -226,6 +230,15 @@ export class R2Service {
return summary;
}
/**
* List the object keys under the user's storage prefix (u/<npub>/) so the
* "My Videos" tab can reconcile notes against actual bucket contents. Unlock-
* gated (needs credentials); returns { ok, reason, keys }.
*/
async listVideoStorageObjects(args = {}) {
return listVideoStorageObjectsHelper(this, args);
}
on(event, handler) {
if (typeof handler !== "function") {
return () => {};
+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: [] };
}
}
+8 -1
View File
@@ -11,6 +11,7 @@ import {
ensureBucketExists,
ensureBucketCors,
deleteObject,
listObjects,
multipartUpload,
} from "./s3-multipart.js";
import { userLogger } from "../utils/logger.js";
@@ -181,4 +182,10 @@ export function makeR2Client({
});
}
export { ensureBucketExists, ensureBucketCors, deleteObject, multipartUpload };
export {
ensureBucketExists,
ensureBucketCors,
deleteObject,
listObjects,
multipartUpload,
};
+1
View File
@@ -28,6 +28,7 @@ function assignAwsSdk(module) {
DeleteObjectCommand: module.DeleteObjectCommand,
PutObjectCommand: module.PutObjectCommand,
GetObjectCommand: module.GetObjectCommand,
ListObjectsV2Command: module.ListObjectsV2Command,
};
}
+35
View File
@@ -155,6 +155,41 @@ export async function deleteObject({ s3, bucket, key } = {}) {
}
}
/**
* List object keys under a prefix, following pagination. Returns plain key
* strings. `maxKeys` is a safety cap so an enormous bucket can't run unbounded.
*/
export async function listObjects({ s3, bucket, prefix = "", maxKeys = 5000 } = {}) {
if (!s3 || !bucket) {
throw new Error("Missing required parameters for listObjects");
}
const { ListObjectsV2Command } = requireAwsSdk();
const keys = [];
let continuationToken;
do {
const response = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: prefix || undefined,
ContinuationToken: continuationToken,
})
);
const contents = Array.isArray(response?.Contents) ? response.Contents : [];
for (const item of contents) {
if (item && typeof item.Key === "string" && item.Key) {
keys.push(item.Key);
}
}
continuationToken = response?.IsTruncated
? response.NextContinuationToken
: undefined;
} while (continuationToken && keys.length < maxKeys);
return keys;
}
export async function multipartUpload({
s3,
bucket,
+83
View File
@@ -0,0 +1,83 @@
// listVideoStorageObjects backs the My Videos tab's bucket reconciliation. It is
// UNLOCK-GATED — it must never attempt a bucket listing without credentials —
// and when unlocked it must list under the user's own prefix (u/<npub>/) so it
// can't enumerate (or later act on) anything outside the user's space.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { listVideoStorageObjects } from "../js/services/r2ServiceHelpers.js";
const NPUB = "npub1exampleexampleexample";
function makeCtx(overrides = {}) {
const calls = { listObjects: [] };
const ctx = {
resolveConnection: async () => null,
ensureS3SdkLoaded: async () => {},
makeR2Client: () => ({}),
makeS3Client: () => ({}),
listObjects: async (args) => {
calls.listObjects.push(args);
return ["u/npub1example/a/v.mp4", "u/npub1example/b/v.mp4"];
},
...overrides,
};
return { ctx, calls };
}
const UNLOCKED = {
provider: "cloudflare_r2",
accountId: "acc",
bucket: "bkt",
accessKeyId: "AK",
secretAccessKey: "SK",
};
test("locked storage never lists (returns storage-locked)", async () => {
const { ctx, calls } = makeCtx();
const result = await listVideoStorageObjects(ctx, {
npub: NPUB,
credentials: { ...UNLOCKED, accessKeyId: "", secretAccessKey: "" },
});
assert.equal(result.ok, false);
assert.equal(result.reason, "storage-locked");
assert.equal(calls.listObjects.length, 0, "must not attempt a listing while locked");
});
test("no resolvable connection returns no-connection", async () => {
const { ctx, calls } = makeCtx();
const result = await listVideoStorageObjects(ctx, { npub: NPUB });
assert.equal(result.reason, "no-connection");
assert.equal(calls.listObjects.length, 0);
});
test("missing bucket is reported and not listed", async () => {
const { ctx, calls } = makeCtx();
const result = await listVideoStorageObjects(ctx, {
npub: NPUB,
credentials: { ...UNLOCKED, bucket: "" },
});
assert.equal(result.reason, "missing-bucket");
assert.equal(calls.listObjects.length, 0);
});
test("unlocked storage lists under the user's own u/<npub>/ prefix", async () => {
const { ctx, calls } = makeCtx();
const result = await listVideoStorageObjects(ctx, {
npub: NPUB,
credentials: UNLOCKED,
});
assert.equal(result.ok, true);
assert.equal(calls.listObjects.length, 1);
assert.equal(
calls.listObjects[0].prefix,
`u/${NPUB}/`,
"must scope the listing to the user's own prefix",
);
assert.equal(calls.listObjects[0].bucket, "bkt");
assert.deepEqual(result.keys, [
"u/npub1example/a/v.mp4",
"u/npub1example/b/v.mp4",
]);
});