mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
bbb8dd0fd8
commit
513f75e4da
6 files changed
+233
-2
No files matched your search
@@ -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 () => {};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -28,6 +28,7 @@ function assignAwsSdk(module) {
|
||||
DeleteObjectCommand: module.DeleteObjectCommand,
|
||||
PutObjectCommand: module.PutObjectCommand,
|
||||
GetObjectCommand: module.GetObjectCommand,
|
||||
ListObjectsV2Command: module.ListObjectsV2Command,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user