mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
feat(my-videos): delete-by-key primitive for orphan cleanup (Phase 2, step 3a)
The bucket listing returns raw keys, but deleteVideoStorage only works from note
objects — so orphan cleanup needs a key-based delete. Adds
r2Service.deleteStorageKeys({ keys, npub|pubkey }) (unlock-gated; deletes exactly
the given keys, records per-key failures without aborting the batch). Extracts a
shared resolveStorageClient (unlock-gated connection+client resolution) now used
by both listVideoStorageObjects and deleteStorageKeys, removing the duplication.
Tests + mutation-verified: never deletes while locked, empty-list no-op, deletes
exactly the given keys, per-key failure isolation. The shared lock gate is
mutation-verified across both list and delete paths.
Build + full lint green (r2Service under its file-size budget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
513f75e4da
commit
41505c2e51
3 files changed
+141
-17
No files matched your search
@@ -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 () => {};
|
||||
|
||||
@@ -238,15 +238,9 @@ export function buildProviderClient(settings, { makeR2Client, makeS3Client } = {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 } = {},
|
||||
) {
|
||||
// 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/<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 } = {},
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user