1 parent
f00c98c355
commit
c58ed12e47
8 files changed
+160
-16
No files matched your search
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export type ResourceRow = {
|
||||
height: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_deleted?: number;
|
||||
};
|
||||
|
||||
export type ResourceListRow = ResourceRow & {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -934,6 +934,9 @@ export const zhCN = {
|
||||
fromMemo: "来自:{{source}}",
|
||||
sourceMemo: "来源笔记:{{source}}",
|
||||
openInNewWindow: "在新窗口打开",
|
||||
deleteTitle: "删除附件",
|
||||
deleteDescription: "附件将从存储空间中永久删除,笔记中的相关链接将无法再打开。此操作无法撤销。",
|
||||
deleteAria: "删除附件 {{filename}}",
|
||||
},
|
||||
tagsDialog: {
|
||||
title: "标签",
|
||||
|
||||
Reference in new issue
Block a user