fix: make attachment deletion recoverable

Fixes #314
This commit is contained in:
tianma-if committed 2026-08-28 19:59:56 +08:00
1 parent f00c98c355
commit c58ed12e47
8 files changed
+160 -16

No files matched your search

+8 -3
View File
@@ -3735,14 +3735,19 @@ const shouldSnapshotMemoRevision = async (
return Date.parse(now) - Date.parse(latest.created_at) >= REVISION_SNAPSHOT_INTERVAL_MS;
};
const getResourceRow = async (db: D1Database, workspaceId: string, id: string): Promise<ResourceRow | null> =>
const getResourceRow = async (
db: D1Database,
workspaceId: string,
id: string,
includeDeleted = false,
): Promise<ResourceRow | null> =>
db
.prepare(
`SELECT r.id, r.memo_id, r.original_memo_id, r.bucket_name, r.object_key, r.storage_config_id, r.kind, r.mime_type,
r.filename, r.byte_size, r.sha256, r.width, r.height, r.created_at, r.updated_at
r.filename, r.byte_size, r.sha256, r.width, r.height, r.created_at, r.updated_at, r.is_deleted
FROM resources r
INNER JOIN memos m ON m.id = r.memo_id
WHERE r.id = ? AND m.workspace_id = ? AND r.is_deleted = 0`
WHERE r.id = ? AND m.workspace_id = ?${includeDeleted ? "" : " AND r.is_deleted = 0"}`
)
.bind(id, workspaceId)
.first<ResourceRow>();
+69
View File
@@ -57,6 +57,7 @@ const createEnvironment = () => ({
const createApp = (auth = agentAuth, getResourceRow = async () => null) => {
const app = new Hono();
app.onError((error, context) => context.json({ error: { message: error.message } }, 500));
app.use("/api/v1/*", async (context, next) => {
context.set("auth", auth);
await next();
@@ -175,4 +176,72 @@ describe("resource route contracts", () => {
expect(response.headers.get("Content-Range")).toBe("bytes */256");
expect(reads).toBe(0);
});
test("deletes object bytes before marking the resource deleted", async () => {
const events = [];
const environment = createEnvironment();
environment.storage.resources = {
delete: async (key) => { events.push(`object:${key}`); },
};
environment.storage.db.batch = async () => { events.push("database"); return []; };
let includeDeleted = false;
const response = await createApp(
{ ...agentAuth, scopes: ["write:resources"] },
async (_database, _workspaceId, _resourceId, requestedIncludeDeleted) => {
includeDeleted = requestedIncludeDeleted;
return { ...resourceRow, storage_config_id: null, is_deleted: 0 };
},
).request(
"/api/v1/resources/res_1",
{ method: "DELETE" },
environment,
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(includeDeleted).toBe(true);
expect(events).toEqual(["object:resource-key", "database"]);
});
test("leaves the database active when object deletion fails so the request can be retried", async () => {
const environment = createEnvironment();
let batches = 0;
environment.storage.resources = {
delete: async () => { throw new Error("storage unavailable"); },
};
environment.storage.db.batch = async () => { batches += 1; return []; };
const response = await createApp(
{ ...agentAuth, scopes: ["write:resources"] },
async () => ({ ...resourceRow, storage_config_id: null, is_deleted: 0 }),
).request(
"/api/v1/resources/res_1",
{ method: "DELETE" },
environment,
);
expect(response.status).toBe(500);
expect(batches).toBe(0);
});
test("retries object cleanup for an already deleted resource without duplicating its audit event", async () => {
const environment = createEnvironment();
let deletes = 0;
let batches = 0;
environment.storage.resources = {
delete: async () => { deletes += 1; },
};
environment.storage.db.batch = async () => { batches += 1; return []; };
const response = await createApp(
{ ...agentAuth, scopes: ["write:resources"] },
async () => ({ ...resourceRow, storage_config_id: null, is_deleted: 1 }),
).request(
"/api/v1/resources/res_1",
{ method: "DELETE" },
environment,
);
expect(response.status).toBe(200);
expect(deletes).toBe(1);
expect(batches).toBe(0);
});
});
+17 -12
View File
@@ -49,6 +49,7 @@ type ResourceRouteDependencies = {
database: DatabaseAdapter,
workspaceId: string,
resourceId: string,
includeDeleted?: boolean,
) => Promise<ResourceRow | null>;
};
@@ -233,23 +234,27 @@ export const registerResourceRoutes = (
context.env.storage.db,
getWorkspaceId(context),
resourceId,
true,
);
if (!resource) return notFound(context, "Resource not found");
const now = isoNow();
const actor = getAuditActor(context);
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`UPDATE resources SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?`,
).bind(now, now, resourceId),
auditStatement(context.env.storage.db, actor.actorType, actor.actorId, "resource.delete", "resource", resourceId, {
memoId: resource.memo_id,
filename: resource.filename,
byteSize: resource.byte_size,
}),
]);
const source = await resolveObjectStorage(context.env, resource.storage_config_id);
await source.store.delete(resource.object_key);
if (!resource.is_deleted) {
const now = isoNow();
const actor = getAuditActor(context);
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`UPDATE resources SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?`,
).bind(now, now, resourceId),
auditStatement(context.env.storage.db, actor.actorType, actor.actorId, "resource.delete", "resource", resourceId, {
memoId: resource.memo_id,
filename: resource.filename,
byteSize: resource.byte_size,
}),
]);
}
return context.json({ ok: true });
});
};
+1
View File
@@ -32,6 +32,7 @@ export type ResourceRow = {
height: number | null;
created_at: string;
updated_at: string;
is_deleted?: number;
};
export type ResourceListRow = ResourceRow & {
+56 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
@@ -18,6 +18,7 @@ import {
List,
X,
Loader2,
Trash2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -29,6 +30,7 @@ import { isPdfAttachment, type ResourceListItem } from "@edgeever/shared";
import { PdfViewer } from "@/components/pdf/PdfViewer";
import { PdfThumbnail } from "@/components/pdf/PdfThumbnail";
import { AttachmentFileIcon } from "@/components/attachments/AttachmentFileIcon";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
export const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
@@ -66,6 +68,7 @@ interface AssetsPaneProps {
export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// States
const [searchQuery, setSearchQuery] = useState("");
@@ -75,6 +78,7 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
});
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [pdfPreview, setPdfPreview] = useState<ResourceListItem | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ResourceListItem | null>(null);
// Query resources
const resourcesQuery = useQuery({
@@ -90,6 +94,19 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
attachmentCount: 0,
};
const deleteMutation = useMutation({
mutationFn: (resourceId: string) => repository.deleteResource(resourceId),
onSuccess: async () => {
setDeleteTarget(null);
await queryClient.invalidateQueries({ queryKey: ["resources"] });
},
});
const requestResourceDelete = (resource: ResourceListItem) => {
deleteMutation.reset();
setDeleteTarget(resource);
};
// Filter Logic
const filteredResources = useMemo(() => {
return resources.filter((resource) => {
@@ -307,6 +324,18 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
key={resource.id}
className="group relative flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-all duration-200 hover:border-emerald-500/40 hover:shadow-md"
>
<ButtonTooltip title={t("assets.deleteAria", { filename: resource.filename || resource.id })}>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t("assets.deleteAria", { filename: resource.filename || resource.id })}
className="absolute right-2 top-2 z-10 h-8 w-8 bg-white/90 text-slate-500 opacity-0 shadow-sm transition-opacity hover:bg-rose-50 hover:text-rose-600 focus:opacity-100 group-hover:opacity-100"
onClick={() => requestResourceDelete(resource)}
>
<Trash2 className="h-4 w-4" />
</Button>
</ButtonTooltip>
{/* Thumbnail area */}
<div
role="button"
@@ -436,6 +465,18 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
</div>
{/* Right Actions */}
<ButtonTooltip title={t("assets.deleteAria", { filename: resource.filename || resource.id })}>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t("assets.deleteAria", { filename: resource.filename || resource.id })}
className="h-8 w-8 text-slate-350 opacity-0 transition-all duration-150 hover:bg-rose-50 hover:text-rose-600 focus:opacity-100 group-hover:opacity-100"
onClick={() => requestResourceDelete(resource)}
>
<Trash2 className="h-4 w-4" />
</Button>
</ButtonTooltip>
<ButtonTooltip title={t("assets.openInNewWindow")}>
<a
href={resource.url}
@@ -477,6 +518,20 @@ export const AssetsPane = ({ onClose, repository }: AssetsPaneProps) => {
onNext={pdfResources.length > 1 ? showNextPdf : undefined}
/>
) : null}
{deleteTarget ? (
<AppConfirmDialog
title={t("assets.deleteTitle")}
description={t("assets.deleteDescription")}
confirmLabel={t("common.delete")}
error={deleteMutation.error instanceof Error ? deleteMutation.error.message : null}
isWorking={deleteMutation.isPending}
onCancel={() => {
deleteMutation.reset();
setDeleteTarget(null);
}}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
/>
) : null}
</div>
);
};
@@ -17,6 +17,7 @@ export const AppConfirmDialog = ({
cancelLabel,
confirmLabel,
description,
error,
hideCancel = false,
isWorking = false,
title,
@@ -28,6 +29,7 @@ export const AppConfirmDialog = ({
cancelLabel?: string;
confirmLabel: string;
description: string;
error?: string | null;
hideCancel?: boolean;
isWorking?: boolean;
title: string;
@@ -60,6 +62,7 @@ export const AppConfirmDialog = ({
<DialogDescription className="mt-1 text-sm leading-5 text-slate-500">
{description}
</DialogDescription>
{error ? <p className="mt-2 text-sm text-rose-600" role="alert">{error}</p> : null}
</div>
</DialogHeader>
<DialogFooter className="flex flex-col-reverse gap-2 px-5 py-4 sm:flex-row sm:justify-end border-t border-slate-50 bg-slate-50/50">
+3
View File
@@ -934,6 +934,9 @@ export const enUS = {
fromMemo: "From: {{source}}",
sourceMemo: "Source note: {{source}}",
openInNewWindow: "Open in new window",
deleteTitle: "Delete attachment",
deleteDescription: "The attachment will be permanently removed from storage. Related links in the note will no longer open. This cannot be undone.",
deleteAria: "Delete attachment {{filename}}",
},
tagsDialog: {
title: "Tags",
+3
View File
@@ -934,6 +934,9 @@ export const zhCN = {
fromMemo: "来自:{{source}}",
sourceMemo: "来源笔记:{{source}}",
openInNewWindow: "在新窗口打开",
deleteTitle: "删除附件",
deleteDescription: "附件将从存储空间中永久删除,笔记中的相关链接将无法再打开。此操作无法撤销。",
deleteAria: "删除附件 {{filename}}",
},
tagsDialog: {
title: "标签",