diff --git a/js/services/r2Service.js b/js/services/r2Service.js index 2e932659..741cc43e 100644 --- a/js/services/r2Service.js +++ b/js/services/r2Service.js @@ -62,6 +62,7 @@ import { safeDecodeNpub, verifyPublicAccess as verifyPublicAccessHelper, listVideoStorageObjects as listVideoStorageObjectsHelper, + deleteStorageKeys as deleteStorageKeysHelper, } from "./r2ServiceHelpers.js"; const STATUS_VARIANTS = new Set(["info", "success", "error", "warning"]); @@ -239,6 +240,14 @@ export class R2Service { return listVideoStorageObjectsHelper(this, args); } + /** + * Delete specific bucket object keys (orphan cleanup). Unlock-gated; returns + * { ok, deleted, failed, reason }. + */ + async deleteStorageKeys(args = {}) { + return deleteStorageKeysHelper(this, args); + } + on(event, handler) { if (typeof handler !== "function") { return () => {}; diff --git a/js/services/r2ServiceHelpers.js b/js/services/r2ServiceHelpers.js index 0e3a1aa9..1943132c 100644 --- a/js/services/r2ServiceHelpers.js +++ b/js/services/r2ServiceHelpers.js @@ -238,15 +238,9 @@ export function buildProviderClient(settings, { makeR2Client, makeS3Client } = { }); } -/** - * 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 } = {}, -) { +// Shared unlock-gated connection+client resolution for the storage-maintenance +// helpers. Returns { settings, s3, bucket } on success or { reason } on failure. +async function resolveStorageClient(ctx, { npub, pubkey, credentials }) { let resolvedNpub = (npub || "").trim(); if (!resolvedNpub && pubkey) { resolvedNpub = safeEncodeNpub(pubkey) || ""; @@ -256,37 +250,82 @@ export async function listVideoStorageObjects( try { settings = await ctx.resolveConnection(resolvedNpub); } catch (err) { - devLogger.warn("[R2Service] listVideoStorageObjects: resolve failed:", err); + devLogger.warn("[R2Service] storage client resolve failed:", err); } } if (!settings) { - return { ok: false, reason: "no-connection", keys: [] }; + return { reason: "no-connection" }; } if ( !(settings.accessKeyId || "").trim() || !(settings.secretAccessKey || "").trim() ) { - return { ok: false, reason: "storage-locked", keys: [] }; + return { reason: "storage-locked" }; } const bucket = (settings.bucket || "").trim(); if (!bucket) { - return { ok: false, reason: "missing-bucket", keys: [] }; + return { reason: "missing-bucket" }; } - let s3; try { const ensureSdk = typeof ctx.ensureS3SdkLoaded === "function" ? ctx.ensureS3SdkLoaded : ensureS3SdkLoaded; await ensureSdk(); - s3 = buildProviderClient(settings, { + const s3 = buildProviderClient(settings, { makeR2Client: ctx.makeR2Client, makeS3Client: ctx.makeS3Client, }); + return { settings, s3, bucket, resolvedNpub }; } catch (err) { - devLogger.warn("[R2Service] listVideoStorageObjects: client build failed:", err); - return { ok: false, reason: "client-error", keys: [] }; + devLogger.warn("[R2Service] storage client build failed:", err); + return { reason: "client-error" }; } +} + +/** + * Delete specific bucket object keys (orphan cleanup from the My Videos tab). + * Unlock-gated; returns { ok, deleted, failed, reason }. + */ +export async function deleteStorageKeys(ctx, { keys = [], npub = "", pubkey = "", credentials = null } = {}) { + const list = (Array.isArray(keys) ? keys : []).filter( + (k) => typeof k === "string" && k.trim(), + ); + if (!list.length) { + return { ok: false, reason: "no-keys", deleted: [], failed: [] }; + } + const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials }); + if (resolved.reason) { + return { ok: false, reason: resolved.reason, deleted: [], failed: [] }; + } + const { s3, bucket } = resolved; + const deleted = []; + const failed = []; + for (const key of list) { + try { + await ctx.deleteObject({ s3, bucket, key }); + deleted.push(key); + } catch (err) { + failed.push({ key, error: err?.message || String(err) }); + } + } + return { ok: true, deleted, failed, reason: "" }; +} + +/** + * 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 } = {}, +) { + const resolved = await resolveStorageClient(ctx, { npub, pubkey, credentials }); + if (resolved.reason) { + return { ok: false, reason: resolved.reason, keys: [] }; + } + const { s3, bucket, resolvedNpub } = resolved; try { const keys = await ctx.listObjects({ s3, diff --git a/tests/my-videos-delete-keys.test.mjs b/tests/my-videos-delete-keys.test.mjs new file mode 100644 index 00000000..5060c88a --- /dev/null +++ b/tests/my-videos-delete-keys.test.mjs @@ -0,0 +1,76 @@ +// deleteStorageKeys reclaims orphaned bucket objects from the My Videos tab. It +// is destructive, so it must be UNLOCK-GATED (never delete without credentials) +// and only ever delete the exact keys it was given. + +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { deleteStorageKeys } from "../js/services/r2ServiceHelpers.js"; + +function makeCtx() { + const deleted = []; + const ctx = { + resolveConnection: async () => null, + ensureS3SdkLoaded: async () => {}, + makeR2Client: () => ({}), + makeS3Client: () => ({}), + deleteObject: async ({ key }) => { + deleted.push(key); + }, + }; + return { ctx, deleted }; +} + +const UNLOCKED = { + provider: "cloudflare_r2", + accountId: "acc", + bucket: "bkt", + accessKeyId: "AK", + secretAccessKey: "SK", +}; + +test("locked storage never deletes", async () => { + const { ctx, deleted } = makeCtx(); + const result = await deleteStorageKeys(ctx, { + keys: ["u/n/a/v.mp4"], + credentials: { ...UNLOCKED, accessKeyId: "", secretAccessKey: "" }, + }); + assert.equal(result.ok, false); + assert.equal(result.reason, "storage-locked"); + assert.equal(deleted.length, 0, "must not delete anything while locked"); +}); + +test("empty key list is a no-op", async () => { + const { ctx, deleted } = makeCtx(); + const result = await deleteStorageKeys(ctx, { keys: [], credentials: UNLOCKED }); + assert.equal(result.reason, "no-keys"); + assert.equal(deleted.length, 0); +}); + +test("unlocked: deletes exactly the given keys", async () => { + const { ctx, deleted } = makeCtx(); + const result = await deleteStorageKeys(ctx, { + keys: ["u/n/a/v.mp4", "u/n/a/v.torrent"], + credentials: UNLOCKED, + }); + assert.equal(result.ok, true); + assert.deepEqual(deleted.sort(), ["u/n/a/v.mp4", "u/n/a/v.torrent"]); + assert.deepEqual(result.deleted.sort(), ["u/n/a/v.mp4", "u/n/a/v.torrent"]); +}); + +test("a per-key failure is recorded but doesn't abort the rest", async () => { + const { ctx, deleted } = makeCtx(); + ctx.deleteObject = async ({ key }) => { + if (key.includes("boom")) { + throw new Error("nope"); + } + deleted.push(key); + }; + const result = await deleteStorageKeys(ctx, { + keys: ["u/n/a/boom.mp4", "u/n/a/ok.mp4"], + credentials: UNLOCKED, + }); + assert.deepEqual(deleted, ["u/n/a/ok.mp4"]); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].key, "u/n/a/boom.mp4"); +});