diff --git a/js/services/r2Service.js b/js/services/r2Service.js index b81e8054..2e932659 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -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//) 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 () => {}; diff --git a/js/services/r2ServiceHelpers.js b/js/services/r2ServiceHelpers.js index f215aa61..0e3a1aa9 100644 --- a/js/services/r2ServiceHelpers.js +++ b/js/services/r2ServiceHelpers.js @@ -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//). `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: [] }; + } +} diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index c2391a43..daf84ca6 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -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, +}; diff --git a/js/storage/s3-client.js b/js/storage/s3-client.js index a32cf069..1c929540 100644 --- a/js/storage/s3-client.js +++ b/js/storage/s3-client.js @@ -28,6 +28,7 @@ function assignAwsSdk(module) { DeleteObjectCommand: module.DeleteObjectCommand, PutObjectCommand: module.PutObjectCommand, GetObjectCommand: module.GetObjectCommand, + ListObjectsV2Command: module.ListObjectsV2Command, }; } diff --git a/js/storage/s3-multipart.js b/js/storage/s3-multipart.js index 9bacb440..cb51968f 100644 --- a/js/storage/s3-multipart.js +++ b/js/storage/s3-multipart.js @@ -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, diff --git a/tests/my-videos-list-objects.test.mjs b/tests/my-videos-list-objects.test.mjs new file mode 100644 index 00000000..528206f4 --- /dev/null +++ b/tests/my-videos-list-objects.test.mjs @@ -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//) 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// 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", + ]); +});