diff --git a/.changeset/cute-geckos-change.md b/.changeset/cute-geckos-change.md new file mode 100644 index 00000000..7d9005f0 --- /dev/null +++ b/.changeset/cute-geckos-change.md @@ -0,0 +1,27 @@ +--- +"r2-explorer": minor +--- + +Trash / Recycle Bin + +Deleted files and folders are now moved to a recoverable trash area inside the same R2 bucket instead of being permanently deleted. A new **Trash** page in the dashboard lets you restore items or delete them forever, and an **Undo** action appears right after deleting a file. + +Example configuration: + +```ts +export default R2Explorer({ + readonly: false, + trash: { + retentionDays: 30, + }, +}); +``` + +To automatically purge expired trash, add a cron trigger to your `wrangler.toml`: + +```toml +[triggers] +crons = ["0 0 * * *"] +``` + +Set `trash: false` to keep the previous hard-delete behavior. diff --git a/packages/dashboard/e2e/trash.spec.ts b/packages/dashboard/e2e/trash.spec.ts new file mode 100644 index 00000000..e917a28e --- /dev/null +++ b/packages/dashboard/e2e/trash.spec.ts @@ -0,0 +1,121 @@ +import { test, expect } from "@playwright/test"; +import { uploadFile, deleteObject, BUCKET } from "./helpers"; + +test.describe("Trash", () => { + test.describe("Delete to trash and restore", () => { + test.beforeEach(async ({ request }) => { + await uploadFile(request, "e2e-trash-me.txt", "trash this"); + }); + + test.afterEach(async ({ request }) => { + await deleteObject(request, "e2e-trash-me.txt"); + // Ensure no restored copy remains + await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, { + data: { key: btoa("e2e-trash-me.txt"), permanent: true }, + }); + }); + + test("moves a deleted file to trash and restores it", async ({ page }) => { + await page.goto(`/${BUCKET}/files`); + await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({ + timeout: 10_000, + }); + + await page.locator("text=e2e-trash-me.txt").click({ button: "right" }); + await page.locator(".q-menu").getByText("Delete").click(); + await page.locator(".q-dialog").getByRole("button", { name: "Delete" }).click(); + + await expect(page.locator(".q-dialog")).not.toBeVisible({ timeout: 5_000 }); + await expect( + page.locator(".q-table").locator("text=e2e-trash-me.txt"), + ).not.toBeVisible({ timeout: 5_000 }); + + // Navigate to trash + await page.getByRole("button", { name: "Trash" }).click(); + await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({ + timeout: 10_000, + }); + + // Restore the file + const row = page.locator("tr", { hasText: "e2e-trash-me.txt" }); + await row.locator("button[aria-label='Restore']").click(); + + await expect( + page.locator(".q-table").locator("text=e2e-trash-me.txt"), + ).not.toBeVisible({ timeout: 5_000 }); + + await page.goto(`/${BUCKET}/files`); + await expect(page.locator("text=e2e-trash-me.txt")).toBeVisible({ + timeout: 10_000, + }); + }); + }); + + test.describe("Empty trash", () => { + test.beforeEach(async ({ request }) => { + await uploadFile(request, "e2e-trash-empty-1.txt", "one"); + await uploadFile(request, "e2e-trash-empty-2.txt", "two"); + await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, { + data: { key: btoa("e2e-trash-empty-1.txt") }, + }); + await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, { + data: { key: btoa("e2e-trash-empty-2.txt") }, + }); + }); + + test("empties the trash via the header button", async ({ page }) => { + await page.goto(`/${BUCKET}/trash`); + await expect(page.locator("text=e2e-trash-empty-1.txt")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator("text=e2e-trash-empty-2.txt")).toBeVisible({ + timeout: 10_000, + }); + + await page.getByRole("button", { name: "Empty trash" }).click(); + await page.locator(".q-dialog").getByRole("button", { name: "OK" }).click(); + + await expect( + page.locator("text=e2e-trash-empty-1.txt"), + ).not.toBeVisible({ timeout: 5_000 }); + await expect( + page.locator("text=e2e-trash-empty-2.txt"), + ).not.toBeVisible({ timeout: 5_000 }); + }); + }); + + test.describe("Undo notification", () => { + test.beforeEach(async ({ request }) => { + await uploadFile(request, "e2e-trash-undo.txt", "undo me"); + }); + + test.afterEach(async ({ request }) => { + await request.post(`${process.env.BASE_URL || "http://localhost:8787"}/api/buckets/${BUCKET}/delete`, { + data: { key: btoa("e2e-trash-undo.txt"), permanent: true }, + }); + }); + + test("shows an undo action after deleting a file", async ({ page }) => { + await page.goto(`/${BUCKET}/files`); + await expect(page.locator("text=e2e-trash-undo.txt")).toBeVisible({ + timeout: 10_000, + }); + + await page.locator("text=e2e-trash-undo.txt").click({ button: "right" }); + await page.locator(".q-menu").getByText("Delete").click(); + await page.locator(".q-dialog").getByRole("button", { name: "Delete" }).click(); + + await expect(page.locator("text=Moved to trash")).toBeVisible({ + timeout: 5_000, + }); + await page.locator("text=Undo").click(); + + await expect(page.locator("text=File restored!")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.locator("text=e2e-trash-undo.txt")).toBeVisible({ + timeout: 5_000, + }); + }); + }); +}); diff --git a/packages/dashboard/src/appUtils.js b/packages/dashboard/src/appUtils.js index 8c5ef7c4..baa11302 100644 --- a/packages/dashboard/src/appUtils.js +++ b/packages/dashboard/src/appUtils.js @@ -125,9 +125,27 @@ export const apiHandler = { key: encode(key), }); }, - deleteObject: (key, bucket) => { + deleteObject: (key, bucket, permanent = false) => { return api.post(`/buckets/${bucket}/delete`, { key: encode(key), + permanent: permanent, + }); + }, + listTrash: async (bucket, cursor = null) => { + return await api.get(`/buckets/${bucket}/trash`, { + params: { + cursor: cursor, + }, + }); + }, + restoreTrash: (bucket, trashKey) => { + return api.post(`/buckets/${bucket}/trash/restore`, { + trashKey: encode(trashKey), + }); + }, + purgeTrash: (bucket, trashKey) => { + return api.post(`/buckets/${bucket}/trash/purge`, { + trashKey: trashKey ? encode(trashKey) : undefined, }); }, downloadFile: ( @@ -288,9 +306,10 @@ export const apiHandler = { }) .map((obj) => mapFile(obj, namePrefix)) .filter((obj) => { - // Remove hidden files + // Remove hidden files and internal explorer paths return !( - mainStore.showHiddenFiles !== true && obj.name.startsWith(".") + (mainStore.showHiddenFiles !== true && obj.name.startsWith(".")) || + obj.key.startsWith(".r2-explorer/") ); }); @@ -303,7 +322,7 @@ export const apiHandler = { const folders = response.data.delimitedPrefixes .map((obj) => ({ name: obj.replace(namePrefix, ""), - hash: encode(obj.key), + hash: encode(obj), key: obj, lastModified: "--", timestamp: 0, @@ -314,9 +333,10 @@ export const apiHandler = { color: "orange", })) .filter((obj) => { - // Remove hidden files + // Remove hidden files and internal explorer paths return !( - mainStore.showHiddenFiles !== true && obj.name.startsWith(".") + (mainStore.showHiddenFiles !== true && obj.name.startsWith(".")) || + obj.key.startsWith(".r2-explorer/") ); }); @@ -358,9 +378,11 @@ export const apiHandler = { }) .map((obj) => mapFile(obj, prefix)) .filter((obj) => { - // Remove hidden files + // Remove hidden files and internal explorer paths return !( - mainStore.showHiddenFiles !== true && obj.name.startsWith(".") + (mainStore.showHiddenFiles !== true && + obj.name.startsWith(".")) || + obj.key.startsWith(".r2-explorer/") ); }); @@ -373,7 +395,7 @@ export const apiHandler = { const folders = response.data.delimitedPrefixes .map((obj) => ({ name: obj.replace(prefix, ""), - hash: encode(obj.key), + hash: encode(obj), key: obj, lastModified: "--", timestamp: 0, @@ -384,9 +406,11 @@ export const apiHandler = { color: "orange", })) .filter((obj) => { - // Remove hidden files + // Remove hidden files and internal explorer paths return !( - mainStore.showHiddenFiles !== true && obj.name.startsWith(".") + (mainStore.showHiddenFiles !== true && + obj.name.startsWith(".")) || + obj.key.startsWith(".r2-explorer/") ); }); diff --git a/packages/dashboard/src/components/files/FileOptions.vue b/packages/dashboard/src/components/files/FileOptions.vue index 6275c5a1..4ebddd8d 100644 --- a/packages/dashboard/src/components/files/FileOptions.vue +++ b/packages/dashboard/src/components/files/FileOptions.vue @@ -8,6 +8,7 @@ files inside? Are you sure you want to delete the file {{row.name}}? + @@ -84,6 +85,7 @@ export default defineComponent({ deleteModal: false, renameModal: false, updateMetadataModal: false, + deletePermanent: false, deleteFolderInnerFilesCount: null, newFolderName: "", renameInput: "", @@ -243,6 +245,8 @@ export default defineComponent({ }); }, deleteConfirm: async function () { + const permanent = this.deletePermanent; + if (this.row.type === "folder") { // When deleting folders, first must copy the objects, because the popup close forces a reset on properties const originalFolder = { ...this.row }; @@ -254,39 +258,83 @@ export default defineComponent({ const notif = this.q.notify({ group: false, spinner: true, - message: "Deleting files...", + message: permanent + ? "Deleting files forever..." + : "Moving files to trash...", caption: "0%", timeout: 0, }); for (const [i, innerFile] of folderContents.entries()) { if (innerFile.key) { - await apiHandler.deleteObject(innerFile.key, this.selectedBucket); + await apiHandler.deleteObject( + innerFile.key, + this.selectedBucket, + permanent, + ); } notif({ caption: `${Number.parseInt((i * 100) / (folderContentsCount + 1))}%`, // +1 because still needs to delete the folder }); } - await apiHandler.deleteObject(originalFolder.key, this.selectedBucket); + await apiHandler.deleteObject( + originalFolder.key, + this.selectedBucket, + permanent, + ); notif({ - icon: "done", // we add an icon - spinner: false, // we reset the spinner setting so the icon can be displayed + icon: "done", + spinner: false, caption: "100%", - message: "Folder deleted!", - timeout: 2500, // we will timeout it in 2.5s + message: permanent + ? "Folder permanently deleted!" + : "Folder moved to trash!", + timeout: 2500, }); } else { this.deleteModal = false; - await apiHandler.deleteObject(this.row.key, this.selectedBucket); - this.q.notify({ - group: false, - icon: "done", // we add an icon - spinner: false, // we reset the spinner setting so the icon can be displayed - message: "File deleted!", - timeout: 2500, // we will timeout it in 2.5s - }); + const resp = await apiHandler.deleteObject( + this.row.key, + this.selectedBucket, + permanent, + ); + + if (!permanent && this.trashEnabled) { + const trashKey = resp.data.trashKey; + this.q.notify({ + group: false, + icon: "delete", + spinner: false, + message: "File moved to trash", + timeout: 5000, + actions: [ + { + label: "Undo", + color: "white", + handler: async () => { + await apiHandler.restoreTrash(this.selectedBucket, trashKey); + this.$bus.emit("fetchFiles"); + this.q.notify({ + group: false, + icon: "restore_from_trash", + message: "File restored!", + timeout: 2500, + }); + }, + }, + ], + }); + } else { + this.q.notify({ + group: false, + icon: "done", + spinner: false, + message: "File deleted!", + timeout: 2500, + }); + } } this.$bus.emit("fetchFiles"); @@ -297,6 +345,7 @@ export default defineComponent({ this.deleteModal = false; this.renameModal = false; this.updateMetadataModal = false; + this.deletePermanent = false; this.renameInput = ""; this.updateCustomMetadata = []; this.updateHttpMetadata = []; @@ -320,6 +369,9 @@ export default defineComponent({ selectedBucket: function () { return this.$route.params.bucket; }, + trashEnabled: function () { + return this.mainStore.config?.trash !== false; + }, selectedFolder: function () { if ( this.$route.params.folder && diff --git a/packages/dashboard/src/components/main/LeftSidebar.vue b/packages/dashboard/src/components/main/LeftSidebar.vue index 7a6d35c7..fdf2834b 100644 --- a/packages/dashboard/src/components/main/LeftSidebar.vue +++ b/packages/dashboard/src/components/main/LeftSidebar.vue @@ -45,6 +45,7 @@ + + +
+
+ + + + + + +
+ + + + + + + + + + + + +
+ +
Loading more files...
+
+ +
+ No more files to load +
+ +
+
+ + + + + diff --git a/packages/dashboard/src/router/routes.js b/packages/dashboard/src/router/routes.js index 30919e7f..590db29d 100644 --- a/packages/dashboard/src/router/routes.js +++ b/packages/dashboard/src/router/routes.js @@ -2,6 +2,7 @@ import MainLayout from "layouts/MainLayout.vue"; import HomePage from "pages/HomePage.vue"; import EmailFolderPage from "pages/email/EmailFolderPage.vue"; import FilesFolderPage from "pages/files/FilesFolderPage.vue"; +import TrashPage from "pages/trash/TrashPage.vue"; const routes = [ { @@ -56,6 +57,12 @@ const routes = [ component: () => import("pages/email/EmailFilePage.vue"), }, + { + path: "/:bucket/trash", + name: "trash-home", + component: TrashPage, + }, + // backwards compatibility { path: "/storage/:bucket", diff --git a/packages/docs/getting-started/configuration.md b/packages/docs/getting-started/configuration.md index 7aca793d..cee8cfa9 100644 --- a/packages/docs/getting-started/configuration.md +++ b/packages/docs/getting-started/configuration.md @@ -12,6 +12,7 @@ Here is all the available options: | `emailRouting` | `object` or `undefined` | Customize Email Explorer, read more [here](/guides/setup-email-explorer.html) | `https://demo.r2explorer.com` | | `cacheAssets` | `boolean` or `undefined` | Cache dashboard assets by 5 minutes, default: `true` | `true` | | `buckets` | `object` or `undefined` | Configure bucket-specific settings like public URLs | `{ BUCKET: { publicUrl: "https://cdn.example.com" } }` | +| `trash` | `object`, `false` or `undefined` | Enable trash / recycle bin, default: `{ retentionDays: 30 }` | `{ retentionDays: 30 }` or `false` | `emailRouting` options: @@ -25,6 +26,12 @@ Here is all the available options: |-------------|--------------------------|------------------------------------------|---------------------------| | `publicUrl` | `string` or `undefined` | Public URL prefix for the bucket's files | `https://cdn.example.com` | +`trash` options: + +| Name | Type(s) | Description | Examples | +|-----------------|-------------------------|------------------------------------------------------------|----------| +| `retentionDays` | `number` or `undefined` | Number of days to keep deleted items before auto-purging | `30` | + ## Disabling readonly mode For security reasons, by default your application will be in read only mode, to disable this, just update your diff --git a/packages/docs/guides/trash-and-recycle-bin.md b/packages/docs/guides/trash-and-recycle-bin.md new file mode 100644 index 00000000..4fb522af --- /dev/null +++ b/packages/docs/guides/trash-and-recycle-bin.md @@ -0,0 +1,188 @@ +# Trash / Recycle Bin + +R2 Explorer includes a built-in trash feature that makes file deletes recoverable. Instead of permanently removing objects, deleted files and folders are moved to a hidden trash area inside the same R2 bucket. You can restore items or permanently delete them from the dashboard at any time. + +## Overview + +With Trash enabled, you can: + +- **Recover deleted files** - Restore accidentally deleted objects to their original location +- **Restore folders** - Entire folders and their contents are preserved in trash +- **Permanently delete** - Free space by deleting individual items or emptying the whole trash +- **Undo recent deletes** - A one-click undo action appears after moving a file to trash +- **Auto-purge old trash** - Use a cron trigger to automatically remove items after a retention period + +## How It Works + +When you delete a file or folder: + +1. R2 Explorer moves the object to `.r2-explorer/trash/{uuid}/{original-key}` in the same bucket +2. The original metadata is preserved, including `customMetadata` +3. Trash metadata (`trashOriginalKey`, `trashDeletedAt`, `trashDeletedBy`) is added to the object +4. The file disappears from the normal file browser + +You can browse trashed items from the **Trash** page in the dashboard. Restoring an item moves it back to its original key. If an object now exists at that key, R2 Explorer restores it to a renamed path like `file (restored 2026-01-01T00-00-00).txt`. + +## Enabling and Disabling Trash + +Trash is **enabled by default** with a 30-day retention window. To change the retention period or disable trash, update your `src/index.ts`: + +```ts +import { R2Explorer } from "r2-explorer"; + +export default R2Explorer({ + readonly: false, + trash: { + retentionDays: 30, // default + }, +}); +``` + +To disable trash and restore hard-delete behavior: + +```ts +import { R2Explorer } from "r2-explorer"; + +export default R2Explorer({ + readonly: false, + trash: false, +}); +``` + +## Automatic Cleanup with Cron Triggers + +You can schedule automatic purging of expired trash by adding a `[triggers]` block to your `wrangler.toml`. Items older than `retentionDays` are permanently deleted. + +```toml +[triggers] +crons = ["0 0 * * *"] +``` + +This example runs once per day at midnight. Cloudflare Workers free plans include support for cron triggers. + +## Using the Dashboard + +### Moving Files to Trash + +1. Right-click any file or folder +2. Select **Delete** +3. Optionally check **Delete permanently (skip trash)** +4. Click **Delete** + +For files, a notification appears with an **Undo** button that restores the file immediately. + +### Restoring Files + +1. Open the **Trash** page from the left sidebar +2. Find the file or folder you want to recover +3. Click the **Restore** button +4. The item returns to its original location + +### Permanently Deleting Items + +- To delete a single item forever: click the **Delete forever** button on the trash row +- To empty the entire trash: click **Empty trash** at the top of the page and confirm + +## API Reference + +### Delete Object + +**Endpoint:** `POST /api/buckets/:bucket/delete` + +**Request Body:** +```json +{ + "key": "base64-encoded-key", + "permanent": false +} +``` + +Set `permanent: true` to skip trash and delete the object forever. + +**Response:** +```json +{ + "success": true, + "trashed": true, + "trashKey": ".r2-explorer/trash/abc123/file.txt" +} +``` + +### List Trash + +**Endpoint:** `GET /api/buckets/:bucket/trash` + +**Response:** +```json +{ + "objects": [ + { + "trashKey": ".r2-explorer/trash/abc123/file.txt", + "originalKey": "file.txt", + "deletedAt": 1762819200000, + "deletedBy": "admin", + "size": 1024 + } + ], + "truncated": false +} +``` + +### Restore from Trash + +**Endpoint:** `POST /api/buckets/:bucket/trash/restore` + +**Request Body:** +```json +{ + "trashKey": "base64-encoded-trash-key" +} +``` + +**Response:** +```json +{ + "success": true, + "restoredKey": "file.txt" +} +``` + +### Purge Trash + +**Endpoint:** `POST /api/buckets/:bucket/trash/purge` + +Delete a single item: +```json +{ + "trashKey": "base64-encoded-trash-key" +} +``` + +Empty the entire trash: +```json +{} +``` + +**Response:** +```json +{ + "success": true, + "deleted": 1 +} +``` + +## Storage Details + +Trash is stored under: +``` +.r2-explorer/trash/{uuid}/{original-key} +``` + +The `{uuid}` segment prevents collisions when the same path is deleted multiple times. Because the original key is preserved in the path, you can also inspect or recover items using any R2 tool (for example, the Cloudflare dashboard or `wrangler r2 object get`). + +## Important Notes + +- Objects under `.r2-explorer/` (shares, emails, thumbnails, and trash itself) are always hard-deleted, never trashed +- Trash metadata is stored as `customMetadata` on the trashed object itself; no sidecar files are used +- Very large objects may not be copyable through the Worker in a single request. If a file cannot be moved to trash, the delete operation returns a clear error and the object is not removed +- Deleting a huge folder happens inside the Worker with pagination. Partial failures are surfaced in the response when possible diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 3bc5475e..8c0992bb 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -20,12 +20,20 @@ import { GetShareLink } from "./modules/buckets/getShareLink"; import { HeadObject } from "./modules/buckets/headObject"; import { ListObjects } from "./modules/buckets/listObjects"; import { ListShares } from "./modules/buckets/listShares"; +import { ListTrash } from "./modules/buckets/listTrash"; import { MoveObject } from "./modules/buckets/moveObject"; import { CompleteUpload } from "./modules/buckets/multipart/completeUpload"; import { CreateUpload } from "./modules/buckets/multipart/createUpload"; import { PartUpload } from "./modules/buckets/multipart/partUpload"; +import { PurgeTrash } from "./modules/buckets/purgeTrash"; import { PutMetadata } from "./modules/buckets/putMetadata"; import { PutObject } from "./modules/buckets/putObject"; +import { RestoreTrash } from "./modules/buckets/restoreTrash"; +import { + getRetentionDays, + isTrashEnabled, + purgeExpiredTrash, +} from "./modules/buckets/trash"; import { dashboardIndex, dashboardRedirect } from "./modules/dashboard"; import { receiveEmail } from "./modules/emails/receiveEmail"; import { SendEmail } from "./modules/emails/sendEmail"; @@ -129,6 +137,12 @@ export function R2Explorer(config?: R2ExplorerConfig) { openapi.post("/api/buckets/:bucket/multipart/upload", PartUpload); openapi.post("/api/buckets/:bucket/multipart/complete", CompleteUpload); openapi.post("/api/buckets/:bucket/delete", DeleteObject); + + // Trash routes + openapi.get("/api/buckets/:bucket/trash", ListTrash); + openapi.post("/api/buckets/:bucket/trash/restore", RestoreTrash); + openapi.post("/api/buckets/:bucket/trash/purge", PurgeTrash); + openapi.on("head", "/api/buckets/:bucket/:key", HeadObject); openapi.get("/api/buckets/:bucket/:key/head", HeadObject); // There are some issues with calling the head method @@ -165,5 +179,30 @@ export function R2Explorer(config?: R2ExplorerConfig) { async fetch(request: Request, env: unknown, context: ExecutionContext) { return app.fetch(request, env as AppEnv, context); }, + async scheduled( + event: { cron: string; scheduledTime: number }, + env: AppEnv, + context: ExecutionContext, + ) { + if (!isTrashEnabled(config)) { + return; + } + + const retentionDays = getRetentionDays(config); + + for (const [bindingName, binding] of Object.entries(env)) { + if (bindingName === "ASSETS") continue; + if ( + binding && + typeof binding === "object" && + "list" in binding && + "delete" in binding + ) { + context.waitUntil( + purgeExpiredTrash(binding as R2Bucket, retentionDays), + ); + } + } + }, }; } diff --git a/packages/worker/src/modules/buckets/copyObject.ts b/packages/worker/src/modules/buckets/copyObject.ts index a139ed13..4039bc84 100644 --- a/packages/worker/src/modules/buckets/copyObject.ts +++ b/packages/worker/src/modules/buckets/copyObject.ts @@ -2,6 +2,7 @@ import { OpenAPIRoute } from "chanfana"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import type { AppContext } from "../../types"; +import { assertNotTrashDestination } from "./trash"; export class CopyObject extends OpenAPIRoute { schema = { @@ -44,6 +45,14 @@ export class CopyObject extends OpenAPIRoute { escape(atob(data.body.destinationKey)), ); + try { + assertNotTrashDestination(destinationKey); + } catch (error) { + throw new HTTPException(400, { + message: error instanceof Error ? error.message : String(error), + }); + } + const object = await bucket.get(sourceKey); if (object === null) { diff --git a/packages/worker/src/modules/buckets/deleteObject.ts b/packages/worker/src/modules/buckets/deleteObject.ts index 9dc173a0..a23fc716 100644 --- a/packages/worker/src/modules/buckets/deleteObject.ts +++ b/packages/worker/src/modules/buckets/deleteObject.ts @@ -2,6 +2,7 @@ import { OpenAPIRoute } from "chanfana"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import type { AppContext } from "../../types"; +import { isExplorerKey, isTrashEnabled, moveToTrash } from "./trash"; export class DeleteObject extends OpenAPIRoute { schema = { @@ -17,6 +18,10 @@ export class DeleteObject extends OpenAPIRoute { "application/json": { schema: z.object({ key: z.string().describe("base64 encoded file key"), + permanent: z + .boolean() + .optional() + .describe("Skip trash and permanently delete the object"), }), }, }, @@ -27,20 +32,41 @@ export class DeleteObject extends OpenAPIRoute { async handle(c: AppContext) { const data = await this.getValidatedData(); - const bucketName = data.params.bucket; // Store bucket name - const bucket = c.env[bucketName] as R2Bucket | undefined; // Explicitly type as potentially undefined + const bucketName = data.params.bucket; + const bucket = c.env[bucketName] as R2Bucket | undefined; if (!bucket) { - // Using Hono's HTTPException for proper error response throw new HTTPException(500, { message: `Bucket binding not found: ${bucketName}`, }); } const key = decodeURIComponent(escape(atob(data.body.key))); + const config = c.get("config"); - await bucket.delete(key); + if (isExplorerKey(key) || data.body.permanent || !isTrashEnabled(config)) { + await bucket.delete(key); + return { success: true }; + } + + const object = await bucket.head(key); + if (object === null) { + await bucket.delete(key); + return { success: true }; + } - return { success: true }; + try { + const { trashKey } = await moveToTrash( + bucket, + key, + crypto.randomUUID().replace(/-/g, "").substring(0, 10), + c.get("authentication_username") || "anonymous", + ); + return { success: true, trashed: true, trashKey }; + } catch (error) { + throw new HTTPException(500, { + message: error instanceof Error ? error.message : String(error), + }); + } } } diff --git a/packages/worker/src/modules/buckets/listTrash.ts b/packages/worker/src/modules/buckets/listTrash.ts new file mode 100644 index 00000000..ccbdfa77 --- /dev/null +++ b/packages/worker/src/modules/buckets/listTrash.ts @@ -0,0 +1,90 @@ +import { OpenAPIRoute } from "chanfana"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import type { AppContext } from "../../types"; +import { TRASH_PREFIX } from "./trash"; + +export class ListTrash extends OpenAPIRoute { + schema = { + operationId: "get-bucket-list-trash", + tags: ["Trash"], + summary: "List trashed objects", + request: { + params: z.object({ + bucket: z.string(), + }), + query: z.object({ + limit: z.number().optional(), + cursor: z.string().nullable().optional(), + }), + }, + responses: { + "200": { + description: "List of trashed objects", + content: { + "application/json": { + schema: z.object({ + objects: z.array( + z.object({ + trashKey: z.string(), + originalKey: z.string(), + deletedAt: z.number(), + deletedBy: z.string(), + size: z.number(), + }), + ), + truncated: z.boolean(), + cursor: z.string().optional(), + }), + }, + }, + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + + const bucketName = data.params.bucket; + const bucket = c.env[bucketName] as R2Bucket | undefined; + + if (!bucket) { + throw new HTTPException(500, { + message: `Bucket binding not found: ${bucketName}`, + }); + } + + const list = await bucket.list({ + prefix: TRASH_PREFIX, + limit: data.query.limit, + cursor: data.query.cursor, + include: ["customMetadata"], + }); + + const objects = list.objects.map((obj) => { + const metadata = obj.customMetadata || {}; + let originalKey = metadata.trashOriginalKey; + if (!originalKey) { + const withoutPrefix = obj.key.slice(TRASH_PREFIX.length); + const firstSlash = withoutPrefix.indexOf("/"); + originalKey = + firstSlash === -1 + ? withoutPrefix + : withoutPrefix.slice(firstSlash + 1); + } + return { + trashKey: obj.key, + originalKey, + deletedAt: Number.parseInt(metadata.trashDeletedAt || "0", 10), + deletedBy: metadata.trashDeletedBy || "anonymous", + size: obj.size, + }; + }); + + return c.json({ + objects, + truncated: list.truncated, + cursor: list.truncated ? list.cursor : undefined, + }); + } +} diff --git a/packages/worker/src/modules/buckets/moveObject.ts b/packages/worker/src/modules/buckets/moveObject.ts index e361ad8c..86a40e8c 100644 --- a/packages/worker/src/modules/buckets/moveObject.ts +++ b/packages/worker/src/modules/buckets/moveObject.ts @@ -2,6 +2,7 @@ import { OpenAPIRoute } from "chanfana"; import { HTTPException } from "hono/http-exception"; import { z } from "zod"; import type { AppContext } from "../../types"; +import { assertNotTrashDestination } from "./trash"; export class MoveObject extends OpenAPIRoute { schema = { @@ -40,6 +41,14 @@ export class MoveObject extends OpenAPIRoute { const oldKey = decodeURIComponent(escape(atob(data.body.oldKey))); const newKey = decodeURIComponent(escape(atob(data.body.newKey))); + try { + assertNotTrashDestination(newKey); + } catch (error) { + throw new HTTPException(400, { + message: error instanceof Error ? error.message : String(error), + }); + } + const object = await bucket.get(oldKey); if (object === null) { diff --git a/packages/worker/src/modules/buckets/purgeTrash.ts b/packages/worker/src/modules/buckets/purgeTrash.ts new file mode 100644 index 00000000..08a23557 --- /dev/null +++ b/packages/worker/src/modules/buckets/purgeTrash.ts @@ -0,0 +1,72 @@ +import { OpenAPIRoute } from "chanfana"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import type { AppContext } from "../../types"; +import { purgeTrash } from "./trash"; + +export class PurgeTrash extends OpenAPIRoute { + schema = { + operationId: "post-bucket-purge-trash", + tags: ["Trash"], + summary: "Permanently delete trashed objects", + request: { + params: z.object({ + bucket: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + trashKey: z + .string() + .optional() + .describe( + "base64 encoded trash key; omit to empty entire trash", + ), + }), + }, + }, + }, + }, + responses: { + "200": { + description: "Trash purged successfully", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + deleted: z.number(), + }), + }, + }, + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + + const bucketName = data.params.bucket; + const bucket = c.env[bucketName] as R2Bucket | undefined; + + if (!bucket) { + throw new HTTPException(500, { + message: `Bucket binding not found: ${bucketName}`, + }); + } + + let trashKey: string | undefined; + if (data.body.trashKey) { + trashKey = decodeURIComponent(escape(atob(data.body.trashKey))); + } + + try { + const { deleted } = await purgeTrash(bucket, trashKey); + return c.json({ success: true, deleted }); + } catch (error) { + throw new HTTPException(500, { + message: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/worker/src/modules/buckets/restoreTrash.ts b/packages/worker/src/modules/buckets/restoreTrash.ts new file mode 100644 index 00000000..c6aa8317 --- /dev/null +++ b/packages/worker/src/modules/buckets/restoreTrash.ts @@ -0,0 +1,64 @@ +import { OpenAPIRoute } from "chanfana"; +import { HTTPException } from "hono/http-exception"; +import { z } from "zod"; +import type { AppContext } from "../../types"; +import { restoreFromTrash } from "./trash"; + +export class RestoreTrash extends OpenAPIRoute { + schema = { + operationId: "post-bucket-restore-trash", + tags: ["Trash"], + summary: "Restore a trashed object", + request: { + params: z.object({ + bucket: z.string(), + }), + body: { + content: { + "application/json": { + schema: z.object({ + trashKey: z.string().describe("base64 encoded trash key"), + }), + }, + }, + }, + }, + responses: { + "200": { + description: "Object restored successfully", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + restoredKey: z.string(), + }), + }, + }, + }, + }, + }; + + async handle(c: AppContext) { + const data = await this.getValidatedData(); + + const bucketName = data.params.bucket; + const bucket = c.env[bucketName] as R2Bucket | undefined; + + if (!bucket) { + throw new HTTPException(500, { + message: `Bucket binding not found: ${bucketName}`, + }); + } + + const trashKey = decodeURIComponent(escape(atob(data.body.trashKey))); + + try { + const { restoredKey } = await restoreFromTrash(bucket, trashKey); + return c.json({ success: true, restoredKey }); + } catch (error) { + throw new HTTPException(500, { + message: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/worker/src/modules/buckets/trash.ts b/packages/worker/src/modules/buckets/trash.ts new file mode 100644 index 00000000..e0635b0b --- /dev/null +++ b/packages/worker/src/modules/buckets/trash.ts @@ -0,0 +1,232 @@ +import type { AppContext, R2ExplorerConfig } from "../../types"; + +export const TRASH_PREFIX = ".r2-explorer/trash/"; +export const EXPLORER_PREFIX = ".r2-explorer/"; + +export type TrashMetadata = { + trashOriginalKey: string; + trashDeletedAt: string; + trashDeletedBy: string; +}; + +export function isTrashEnabled(config: R2ExplorerConfig): boolean { + return config.trash !== false; +} + +export function getRetentionDays(config: R2ExplorerConfig): number { + if ( + config.trash && + typeof config.trash === "object" && + config.trash.retentionDays + ) { + return config.trash.retentionDays; + } + return 30; +} + +export function isExplorerKey(key: string): boolean { + return key.startsWith(EXPLORER_PREFIX); +} + +export function generateTrashId(): string { + return crypto.randomUUID().replace(/-/g, "").substring(0, 10); +} + +export function getTrashKey(originalKey: string, trashId?: string): string { + const id = trashId || generateTrashId(); + return `${TRASH_PREFIX}${id}/${originalKey}`; +} + +export function parseTrashKey( + trashKey: string, +): { trashId: string; originalKey: string } | null { + if (!trashKey.startsWith(TRASH_PREFIX)) { + return null; + } + + const withoutPrefix = trashKey.slice(TRASH_PREFIX.length); + const firstSlash = withoutPrefix.indexOf("/"); + if (firstSlash === -1) { + return null; + } + + const trashId = withoutPrefix.slice(0, firstSlash); + const originalKey = withoutPrefix.slice(firstSlash + 1); + return { trashId, originalKey }; +} + +export async function moveToTrash( + bucket: R2Bucket, + key: string, + trashId: string, + deletedBy: string, +): Promise<{ trashKey: string }> { + const object = await bucket.get(key); + + if (object === null) { + throw new Error(`Object not found: ${key}`); + } + + const trashKey = getTrashKey(key, trashId); + const deletedAt = Date.now().toString(); + + const customMetadata: Record = { + ...(object.customMetadata || {}), + trashOriginalKey: key, + trashDeletedAt: deletedAt, + trashDeletedBy: deletedBy, + }; + + try { + await bucket.put(trashKey, object.body, { + customMetadata, + httpMetadata: object.httpMetadata, + }); + } catch (error) { + // Large objects (>5GB or multipart) cannot always be copied via put(). + // Re-throw a clear error so callers can fall back to hard delete. + throw new Error( + `Failed to copy object to trash (object may be too large to copy in one request): ${error instanceof Error ? error.message : String(error)}`, + ); + } + + await bucket.delete(key); + + return { trashKey }; +} + +export async function restoreFromTrash( + bucket: R2Bucket, + trashKey: string, +): Promise<{ restoredKey: string }> { + const object = await bucket.get(trashKey); + + if (object === null) { + throw new Error(`Trashed object not found: ${trashKey}`); + } + + const customMetadata = { ...(object.customMetadata || {}) }; + let originalKey = customMetadata.trashOriginalKey; + delete customMetadata.trashOriginalKey; + delete customMetadata.trashDeletedAt; + delete customMetadata.trashDeletedBy; + + if (!originalKey) { + const parsed = parseTrashKey(trashKey); + if (!parsed) { + throw new Error(`Invalid trash key: ${trashKey}`); + } + originalKey = parsed.originalKey; + } + + let restoredKey = originalKey; + const existing = await bucket.head(originalKey); + if (existing) { + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const lastSlash = originalKey.lastIndexOf("/"); + if (lastSlash !== -1 && originalKey.endsWith("/")) { + // folder marker + restoredKey = `${originalKey.slice(0, -1)} (restored ${timestamp})/`; + } else if (lastSlash !== -1) { + const folder = originalKey.slice(0, lastSlash + 1); + const name = originalKey.slice(lastSlash + 1); + const lastDot = name.lastIndexOf("."); + if (lastDot > 0) { + restoredKey = `${folder}${name.slice(0, lastDot)} (restored ${timestamp})${name.slice(lastDot)}`; + } else { + restoredKey = `${folder}${name} (restored ${timestamp})`; + } + } else { + const lastDot = originalKey.lastIndexOf("."); + if (lastDot > 0) { + restoredKey = `${originalKey.slice(0, lastDot)} (restored ${timestamp})${originalKey.slice(lastDot)}`; + } else { + restoredKey = `${originalKey} (restored ${timestamp})`; + } + } + } + + await bucket.put(restoredKey, object.body, { + customMetadata, + httpMetadata: object.httpMetadata, + }); + + await bucket.delete(trashKey); + + return { restoredKey }; +} + +export async function purgeTrash( + bucket: R2Bucket, + trashKey?: string, +): Promise<{ deleted: number }> { + if (trashKey) { + await bucket.delete(trashKey); + return { deleted: 1 }; + } + + let deleted = 0; + let truncated = true; + let cursor: string | undefined; + + while (truncated) { + const list = await bucket.list({ + prefix: TRASH_PREFIX, + cursor, + }); + + const keys = list.objects.map((obj) => obj.key); + if (keys.length > 0) { + await bucket.delete(keys); + deleted += keys.length; + } + + truncated = list.truncated; + cursor = list.truncated ? list.cursor : undefined; + } + + return { deleted }; +} + +export async function purgeExpiredTrash( + bucket: R2Bucket, + retentionDays: number, + now = Date.now(), +): Promise<{ deleted: number }> { + const cutoff = now - retentionDays * 24 * 60 * 60 * 1000; + let deleted = 0; + let truncated = true; + let cursor: string | undefined; + + while (truncated) { + const list = await bucket.list({ + prefix: TRASH_PREFIX, + include: ["customMetadata"], + cursor, + }); + + const keysToDelete: string[] = []; + for (const obj of list.objects) { + const deletedAt = obj.customMetadata?.trashDeletedAt; + if (deletedAt && Number.parseInt(deletedAt, 10) < cutoff) { + keysToDelete.push(obj.key); + } + } + + if (keysToDelete.length > 0) { + await bucket.delete(keysToDelete); + deleted += keysToDelete.length; + } + + truncated = list.truncated; + cursor = list.truncated ? list.cursor : undefined; + } + + return { deleted }; +} + +export function assertNotTrashDestination(key: string) { + if (key.startsWith(TRASH_PREFIX)) { + throw new Error("Cannot move or copy objects into the trash area directly"); + } +} diff --git a/packages/worker/src/types.d.ts b/packages/worker/src/types.d.ts index 8424e84e..6528569e 100644 --- a/packages/worker/src/types.d.ts +++ b/packages/worker/src/types.d.ts @@ -10,6 +10,10 @@ export type BucketConfig = { publicUrl?: string; }; +export type TrashConfig = { + retentionDays?: number; +}; + export type R2ExplorerConfig = { readonly?: boolean; cors?: boolean; @@ -23,6 +27,7 @@ export type R2ExplorerConfig = { showHiddenFiles?: boolean; basicAuth?: BasicAuth | BasicAuth[]; buckets?: Record; + trash?: TrashConfig | false; }; export type ShareMetadata = { diff --git a/packages/worker/tests/integration/buckets.test.ts b/packages/worker/tests/integration/buckets.test.ts index c08e95bb..55ff77cc 100644 --- a/packages/worker/tests/integration/buckets.test.ts +++ b/packages/worker/tests/integration/buckets.test.ts @@ -157,7 +157,9 @@ describe("Bucket Endpoints", () => { const request = createTestRequest("/api/buckets/NON_EXISTENT_BUCKET"); const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(500); - expect(await response.text()).toBe("Bucket binding not found: NON_EXISTENT_BUCKET"); + expect(await response.text()).toBe( + "Bucket binding not found: NON_EXISTENT_BUCKET", + ); }); }); @@ -358,9 +360,13 @@ describe("Bucket Endpoints", () => { const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(200); const body = await response.json(); - expect(body).toEqual({ success: true }); + expect(body.success).toBe(true); + expect(body.trashed).toBe(true); + expect(body.trashKey).toMatch( + /^\.r2-explorer\/trash\/[a-z0-9]+\/to-be-deleted\.txt$/, + ); - // Verify it's deleted + // Verify it's deleted from original location r2Object = await MY_TEST_BUCKET_1.head(objectKey); expect(r2Object).toBeNull(); }); @@ -411,7 +417,11 @@ describe("Bucket Endpoints", () => { const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(200); const body = await response.json(); - expect(body).toEqual({ success: true }); + expect(body.success).toBe(true); + expect(body.trashed).toBe(true); + expect(body.trashKey).toMatch( + /^\.r2-explorer\/trash\/[a-z0-9]+\/folder\/path with spaces\/file\?name\.txt$/, + ); r2Object = await MY_TEST_BUCKET_1.head(objectKey); expect(r2Object).toBeNull(); diff --git a/packages/worker/tests/integration/copy.test.ts b/packages/worker/tests/integration/copy.test.ts index 0b1285fc..ec34f857 100644 --- a/packages/worker/tests/integration/copy.test.ts +++ b/packages/worker/tests/integration/copy.test.ts @@ -1,6 +1,6 @@ +import { createExecutionContext, env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestApp, createTestRequest } from "./setup"; -import { env, createExecutionContext } from "cloudflare:test"; describe("CopyObject (POST /api/buckets/:bucket/copy)", () => { let app: ReturnType; @@ -55,13 +55,13 @@ describe("CopyObject (POST /api/buckets/:bucket/copy)", () => { // Verify copy exists const copyObj = await MY_TEST_BUCKET_1.get(destKey); expect(copyObj).not.toBeNull(); - const copyContent = await copyObj!.text(); + const copyContent = await copyObj?.text(); expect(copyContent).toBe(SOURCE_CONTENT); // Verify original still exists const sourceObj = await MY_TEST_BUCKET_1.get(SOURCE_KEY); expect(sourceObj).not.toBeNull(); - const sourceContent = await sourceObj!.text(); + const sourceContent = await sourceObj?.text(); expect(sourceContent).toBe(SOURCE_CONTENT); }); diff --git a/packages/worker/tests/integration/multipart.test.ts b/packages/worker/tests/integration/multipart.test.ts index 672b16e9..9413917e 100644 --- a/packages/worker/tests/integration/multipart.test.ts +++ b/packages/worker/tests/integration/multipart.test.ts @@ -109,8 +109,7 @@ describe("Multipart Upload Endpoints", () => { }); it("should return 400 for malformed customMetadata", async () => { - if (!MY_TEST_BUCKET_1) - throw new Error("MY_TEST_BUCKET_1 not available"); + if (!MY_TEST_BUCKET_1) throw new Error("MY_TEST_BUCKET_1 not available"); const objectKey = "bad-metadata.dat"; const base64ObjectKey = btoa(objectKey); @@ -130,8 +129,7 @@ describe("Multipart Upload Endpoints", () => { }); it("should return 400 for malformed httpMetadata", async () => { - if (!MY_TEST_BUCKET_1) - throw new Error("MY_TEST_BUCKET_1 not available"); + if (!MY_TEST_BUCKET_1) throw new Error("MY_TEST_BUCKET_1 not available"); const objectKey = "bad-metadata.dat"; const base64ObjectKey = btoa(objectKey); diff --git a/packages/worker/tests/integration/object.test.ts b/packages/worker/tests/integration/object.test.ts index eb7f82a5..97e07de8 100644 --- a/packages/worker/tests/integration/object.test.ts +++ b/packages/worker/tests/integration/object.test.ts @@ -1,7 +1,7 @@ +import { Buffer } from "node:buffer"; // For btoa +import { createExecutionContext, env } from "cloudflare:test"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestApp, createTestRequest } from "./setup"; -import { env, createExecutionContext } from "cloudflare:test"; -import { Buffer } from "node:buffer"; // For btoa // Helper function to convert ArrayBuffer to string function arrayBufferToString(buffer: ArrayBuffer) { @@ -53,7 +53,9 @@ describe("Object Specific Endpoints", () => { const currentTestBucket = env.MY_TEST_BUCKET_1; // Re-fetch bucket from env // If currentTestBucket was null or undefined, this could indicate an issue with env availability if (!currentTestBucket) { - console.error("MY_TEST_BUCKET_1 is not available in this test context prior to request."); + console.error( + "MY_TEST_BUCKET_1 is not available in this test context prior to request.", + ); // Optionally, force a failure or throw to make it clear // throw new Error("MY_TEST_BUCKET_1 binding missing in test."); } @@ -128,7 +130,9 @@ describe("Object Specific Endpoints", () => { const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(500); const bodyText = await response.text(); - expect(bodyText).toContain("Bucket binding not found: NON_EXISTENT_BUCKET"); + expect(bodyText).toContain( + "Bucket binding not found: NON_EXISTENT_BUCKET", + ); }); }); @@ -144,11 +148,17 @@ describe("Object Specific Endpoints", () => { const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(TEST_OBJECT_CONTENT_TYPE); - expect(response.headers.get("content-length")).toBe(TEST_OBJECT_CONTENT.length.toString()); + expect(response.headers.get("content-type")).toBe( + TEST_OBJECT_CONTENT_TYPE, + ); + expect(response.headers.get("content-length")).toBe( + TEST_OBJECT_CONTENT.length.toString(), + ); expect(response.headers.has("etag")).toBe(true); // Default R2 GetObject includes Content-Disposition with sanitized filename and RFC 5987 filename* - expect(response.headers.get("content-disposition")).toBe(`attachment; filename="${TEST_OBJECT_KEY}"; filename*=UTF-8''${encodeURIComponent(TEST_OBJECT_KEY)}`); + expect(response.headers.get("content-disposition")).toBe( + `attachment; filename="${TEST_OBJECT_KEY}"; filename*=UTF-8''${encodeURIComponent(TEST_OBJECT_KEY)}`, + ); const body = await response.text(); expect(body).toBe(TEST_OBJECT_CONTENT); @@ -173,7 +183,7 @@ describe("Object Specific Endpoints", () => { ); const response = await app.fetch(request, env, createExecutionContext()); expect(response.status).toBe(500); - const body = (await response.text()); + const body = await response.text(); expect(body).toContain("Bucket binding not found: NON_EXISTENT_BUCKET"); }); @@ -204,10 +214,12 @@ describe("Object Specific Endpoints", () => { // The PutMetadata handler takes /:key, not /:key/metadata const METADATA_URL = `/api/buckets/${BUCKET_NAME}/${base64Key}`; - it("should add new custom and update http metadata to an existing object", async () => { const newCustomMetadata = { project: "r2-explorer", version: "1.0" }; - const newHttpMetadata = { contentType: "application/octet-stream", cacheControl: "max-age=3600" }; + const newHttpMetadata = { + contentType: "application/octet-stream", + cacheControl: "max-age=3600", + }; const request = createTestRequest( METADATA_URL, @@ -223,8 +235,12 @@ describe("Object Specific Endpoints", () => { const headResponse = await MY_TEST_BUCKET_1.head(TEST_OBJECT_KEY); expect(headResponse).not.toBeNull(); expect(headResponse?.customMetadata).toEqual(newCustomMetadata); - expect(headResponse?.httpMetadata?.contentType).toBe(newHttpMetadata.contentType); - expect(headResponse?.httpMetadata?.cacheControl).toBe(newHttpMetadata.cacheControl); + expect(headResponse?.httpMetadata?.contentType).toBe( + newHttpMetadata.contentType, + ); + expect(headResponse?.httpMetadata?.cacheControl).toBe( + newHttpMetadata.cacheControl, + ); }); it("should update existing custom metadata", async () => { @@ -234,11 +250,18 @@ describe("Object Specific Endpoints", () => { httpMetadata: { contentType: TEST_OBJECT_CONTENT_TYPE }, }); - const updatedCustomMetadata = { initial: "value", to_update: "new_value", added: "another" }; + const updatedCustomMetadata = { + initial: "value", + to_update: "new_value", + added: "another", + }; const request = createTestRequest( METADATA_URL, "POST", - { customMetadata: updatedCustomMetadata, httpMetadata: { contentType: TEST_OBJECT_CONTENT_TYPE } }, + { + customMetadata: updatedCustomMetadata, + httpMetadata: { contentType: TEST_OBJECT_CONTENT_TYPE }, + }, { "Content-Type": "application/json" }, ); const response = await app.fetch(request, env, createExecutionContext()); @@ -256,7 +279,10 @@ describe("Object Specific Endpoints", () => { const request = createTestRequest( METADATA_URL, "POST", - { customMetadata: {}, httpMetadata: {contentType: TEST_OBJECT_CONTENT_TYPE} }, // Empty custom metadata + { + customMetadata: {}, + httpMetadata: { contentType: TEST_OBJECT_CONTENT_TYPE }, + }, // Empty custom metadata { "Content-Type": "application/json" }, ); const response = await app.fetch(request, env, createExecutionContext()); @@ -267,7 +293,6 @@ describe("Object Specific Endpoints", () => { expect(headResponse?.customMetadata).toEqual({}); }); - it("should return 404 when attempting to update metadata for a non-existent object", async () => { const nonExistentBase64Key = btoa("ghost.txt"); const request = createTestRequest( @@ -300,7 +325,7 @@ describe("Object Specific Endpoints", () => { { "Content-Type": "application/json" }, ); const response = await app.fetch(request, env, createExecutionContext()); - const body = (await response.text()); + const body = await response.text(); expect(response.status).toBe(500); expect(body).toContain("Bucket binding not found: NON_EXISTENT_BUCKET"); }); diff --git a/packages/worker/tests/integration/trash.test.ts b/packages/worker/tests/integration/trash.test.ts new file mode 100644 index 00000000..64f3a519 --- /dev/null +++ b/packages/worker/tests/integration/trash.test.ts @@ -0,0 +1,335 @@ +import { createExecutionContext, env } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestApp, createTestRequest } from "./setup"; + +function b64(key: string): string { + return btoa(key); +} + +describe("Trash Endpoints", () => { + let app: ReturnType; + let bucket: R2Bucket; + const BUCKET_NAME = "MY_TEST_BUCKET_1"; + + beforeEach(async () => { + app = createTestApp(); + bucket = env.MY_TEST_BUCKET_1; + + // Clean bucket + const listed = await bucket.list(); + const keys = listed.objects.map((obj) => obj.key); + if (keys.length > 0) { + await bucket.delete(keys); + } + }); + + afterEach(async () => { + const listed = await bucket.list(); + const keys = listed.objects.map((obj) => obj.key); + if (keys.length > 0) { + await bucket.delete(keys); + } + }); + + it("should move a file to trash on delete", async () => { + await bucket.put("file.txt", "hello", { + httpMetadata: { contentType: "text/plain" }, + customMetadata: { keep: "value" }, + }); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("file.txt") }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.trashed).toBe(true); + expect(body.trashKey).toMatch( + /^\.r2-explorer\/trash\/[a-z0-9]+\/file\.txt$/, + ); + + const original = await bucket.head("file.txt"); + expect(original).toBeNull(); + + const trashed = await bucket.head(body.trashKey); + expect(trashed).not.toBeNull(); + expect(trashed?.customMetadata?.trashOriginalKey).toBe("file.txt"); + expect(trashed?.customMetadata?.keep).toBe("value"); + expect(trashed?.customMetadata?.trashDeletedBy).toBe("anonymous"); + }); + + it("should permanently delete when permanent flag is true", async () => { + await bucket.put("file.txt", "hello"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("file.txt"), permanent: true }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.trashed).toBeUndefined(); + + const listed = await bucket.list(); + expect(listed.objects).toHaveLength(0); + }); + + it("should hard-delete objects under .r2-explorer", async () => { + await bucket.put(".r2-explorer/sharable-links/abc.json", "{}"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64(".r2-explorer/sharable-links/abc.json") }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + + expect(response.status).toBe(200); + const listed = await bucket.list(); + expect(listed.objects).toHaveLength(0); + }); + + it("should hard-delete when trash is disabled", async () => { + app = createTestApp({ trash: false }); + await bucket.put("file.txt", "hello"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("file.txt") }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.trashed).toBeUndefined(); + + const listed = await bucket.list(); + expect(listed.objects).toHaveLength(0); + }); + + it("should list trashed objects", async () => { + await bucket.put("folder/file.txt", "hello"); + const req = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("folder/file.txt") }, + ); + const delResp = await app.fetch(req, env, createExecutionContext()); + const delBody = await delResp.json(); + + const listReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/trash`, + "GET", + ); + const listResp = await app.fetch(listReq, env, createExecutionContext()); + const listBody = await listResp.json(); + + expect(listResp.status).toBe(200); + expect(listBody.objects).toHaveLength(1); + expect(listBody.objects[0].originalKey).toBe("folder/file.txt"); + expect(listBody.objects[0].trashKey).toBe(delBody.trashKey); + expect(listBody.objects[0].deletedBy).toBe("anonymous"); + expect(listBody.objects[0].size).toBe(5); + }); + + it("should restore a trashed object", async () => { + await bucket.put("file.txt", "hello"); + const delReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("file.txt") }, + ); + const delResp = await app.fetch(delReq, env, createExecutionContext()); + const delBody = await delResp.json(); + + const restoreReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/trash/restore`, + "POST", + { trashKey: b64(delBody.trashKey) }, + ); + const restoreResp = await app.fetch( + restoreReq, + env, + createExecutionContext(), + ); + const restoreBody = await restoreResp.json(); + + expect(restoreResp.status).toBe(200); + expect(restoreBody.success).toBe(true); + expect(restoreBody.restoredKey).toBe("file.txt"); + + const restored = await bucket.head("file.txt"); + expect(restored).not.toBeNull(); + expect(restored?.customMetadata?.trashOriginalKey).toBeUndefined(); + expect(restored?.customMetadata?.keep).toBeUndefined(); + }); + + it("should restore to renamed path when original exists", async () => { + await bucket.put("file.txt", "original"); + await bucket.put("file.txt", "trashed", { + customMetadata: { + trashOriginalKey: "file.txt", + trashDeletedAt: "1", + trashDeletedBy: "test", + }, + }); + const trashKey = ".r2-explorer/trash/abc123/file.txt"; + // move the trashed object to the trash key manually to simulate trash + const obj = await bucket.get("file.txt"); + await bucket.put(trashKey, obj.body, { + customMetadata: obj.customMetadata, + httpMetadata: obj.httpMetadata, + }); + + const restoreReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/trash/restore`, + "POST", + { trashKey: b64(trashKey) }, + ); + const restoreResp = await app.fetch( + restoreReq, + env, + createExecutionContext(), + ); + const restoreBody = await restoreResp.json(); + + expect(restoreResp.status).toBe(200); + expect(restoreBody.restoredKey).toMatch(/^file \(restored .*\)\.txt$/); + + const restored = await bucket.head(restoreBody.restoredKey); + expect(restored).not.toBeNull(); + }); + + it("should purge a single trashed object", async () => { + await bucket.put("file.txt", "hello"); + const delReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/delete`, + "POST", + { key: b64("file.txt") }, + ); + const delResp = await app.fetch(delReq, env, createExecutionContext()); + const delBody = await delResp.json(); + + const purgeReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/trash/purge`, + "POST", + { trashKey: b64(delBody.trashKey) }, + ); + const purgeResp = await app.fetch(purgeReq, env, createExecutionContext()); + const purgeBody = await purgeResp.json(); + + expect(purgeResp.status).toBe(200); + expect(purgeBody.deleted).toBe(1); + + const listed = await bucket.list(); + expect(listed.objects).toHaveLength(0); + }); + + it("should empty entire trash", async () => { + await bucket.put("a.txt", "a"); + await bucket.put("b.txt", "b"); + await app.fetch( + createTestRequest(`/api/buckets/${BUCKET_NAME}/delete`, "POST", { + key: b64("a.txt"), + }), + env, + createExecutionContext(), + ); + await app.fetch( + createTestRequest(`/api/buckets/${BUCKET_NAME}/delete`, "POST", { + key: b64("b.txt"), + }), + env, + createExecutionContext(), + ); + + const purgeReq = createTestRequest( + `/api/buckets/${BUCKET_NAME}/trash/purge`, + "POST", + {}, + ); + const purgeResp = await app.fetch(purgeReq, env, createExecutionContext()); + const purgeBody = await purgeResp.json(); + + expect(purgeResp.status).toBe(200); + expect(purgeBody.deleted).toBe(2); + + const listed = await bucket.list(); + expect(listed.objects).toHaveLength(0); + }); + + it("should reject move into trash", async () => { + await bucket.put("file.txt", "hello"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/move`, + "POST", + { + oldKey: b64("file.txt"), + newKey: b64(".r2-explorer/trash/abc/file.txt"), + }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + + expect(response.status).toBe(400); + }); + + it("should reject copy into trash", async () => { + await bucket.put("file.txt", "hello"); + + const request = createTestRequest( + `/api/buckets/${BUCKET_NAME}/copy`, + "POST", + { + sourceKey: b64("file.txt"), + destinationKey: b64(".r2-explorer/trash/abc/file.txt"), + }, + ); + const response = await app.fetch(request, env, createExecutionContext()); + + expect(response.status).toBe(400); + }); + + it("should purge expired trash on scheduled event", async () => { + const now = Date.now(); + const oldTrashKey = ".r2-explorer/trash/old/file.txt"; + const newTrashKey = ".r2-explorer/trash/new/file.txt"; + + await bucket.put(oldTrashKey, "old", { + customMetadata: { + trashOriginalKey: "file.txt", + trashDeletedAt: (now - 31 * 24 * 60 * 60 * 1000).toString(), + trashDeletedBy: "test", + }, + }); + await bucket.put(newTrashKey, "new", { + customMetadata: { + trashOriginalKey: "file.txt", + trashDeletedAt: now.toString(), + trashDeletedBy: "test", + }, + }); + + const context = createExecutionContext(); + await app.scheduled( + { cron: "0 0 * * *", scheduledTime: now }, + env, + context, + ); + // waitUntil promises may still be running; force a small delay + await new Promise((resolve) => setTimeout(resolve, 100)); + + const listed = await bucket.list({ prefix: ".r2-explorer/trash/" }); + expect(listed.objects).toHaveLength(1); + expect(listed.objects[0].key).toBe(newTrashKey); + }); +}); diff --git a/packages/worker/tests/tsconfig.json b/packages/worker/tests/tsconfig.json index a20ca84e..afdc5cd0 100644 --- a/packages/worker/tests/tsconfig.json +++ b/packages/worker/tests/tsconfig.json @@ -3,9 +3,9 @@ "compilerOptions": { "moduleResolution": "bundler", "types": [ - "bindings.d.ts", + "bindings.d.ts", "@cloudflare/workers-types/experimental", - "@cloudflare/vitest-pool-workers", + "@cloudflare/vitest-pool-workers" ] }, "include": ["./**/*.ts"] diff --git a/packages/worker/tests/vitest.config.mts b/packages/worker/tests/vitest.config.mts index a1a76295..a962c937 100644 --- a/packages/worker/tests/vitest.config.mts +++ b/packages/worker/tests/vitest.config.mts @@ -8,7 +8,7 @@ export default defineWorkersConfig({ test: { poolOptions: { workers: { - singleWorker: true, + singleWorker: true, wrangler: { configPath: "../dev/wrangler.toml" }, miniflare: { compatibilityDate: "2024-11-06", // Or your project's compatibility date diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index afecece4..0be81293 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,11 @@ packages: -# - "template" + # - "template" - "packages/*" - "packages/worker/dev" +allowBuilds: + '@biomejs/biome': true + '@parcel/watcher': true + esbuild: true + sharp: true + vue-demi: true + workerd: true diff --git a/template/src/index.ts b/template/src/index.ts index e8405ff1..32681561 100644 --- a/template/src/index.ts +++ b/template/src/index.ts @@ -7,4 +7,7 @@ export default R2Explorer({ // username: "username", // password: "password", // }, + // trash: { + // retentionDays: 30, + // }, });