backend: expand bookmark API with bulk ops and metadata fetcher

- bulk create/delete/move, reorder, rename-category endpoints
- /bookmarks/meta with SSRF-safe fetcher (blocks private/loopback IPs,
  8s timeout, 1 MiB body cap)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-05-02 22:52:43 +00:00
parent 487b4c42c4
commit 832512469a
6 changed files with 474 additions and 52 deletions

View File

@@ -1,321 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Card, CardContent } from "@/components/ui/card";
import {
Pencil,
Trash2,
Plus,
ExternalLink,
Globe,
} from "lucide-react";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
sortOrder: number;
}
export function BookmarkManager() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Bookmark | null>(null);
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [category, setCategory] = useState("");
const [sortOrder, setSortOrder] = useState(0);
async function fetchBookmarks() {
setLoading(true);
try {
const res = await fetch("/api/proxy/bookmarks");
if (!res.ok) throw new Error("failed to fetch");
const data = await res.json();
setBookmarks(data.bookmarks || []);
setCategories(data.categories || []);
} catch {
setBookmarks([]);
setCategories([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchBookmarks();
}, []);
function openAdd() {
setEditing(null);
setTitle("");
setUrl("");
setDescription("");
setIcon("");
setCategory("");
setSortOrder(0);
setDialogOpen(true);
}
function openEdit(bm: Bookmark) {
setEditing(bm);
setTitle(bm.title);
setUrl(bm.url);
setDescription(bm.description);
setIcon(bm.icon);
setCategory(bm.category);
setSortOrder(bm.sortOrder);
setDialogOpen(true);
}
async function handleSave() {
const payload = {
title,
url,
description,
icon,
category: category || "默认",
sortOrder,
};
try {
const res = editing
? await fetch(`/api/proxy/bookmarks/${editing.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch("/api/proxy/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("save failed");
setDialogOpen(false);
fetchBookmarks();
} catch {
alert("保存失败,请检查输入");
}
}
async function handleDelete(id: number) {
if (!confirm("确定要删除这个书签吗?")) return;
try {
const res = await fetch(`/api/proxy/bookmarks/${id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("delete failed");
fetchBookmarks();
} catch {
alert("删除失败");
}
}
const grouped = bookmarks.reduce<Record<string, Bookmark[]>>((acc, bm) => {
const cat = bm.category || "默认";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(bm);
return acc;
}, {});
if (loading) {
return <p className="text-muted-foreground">...</p>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{bookmarks.length}
</p>
<Button size="sm" onClick={openAdd}>
<Plus className="mr-1 size-4" />
</Button>
</div>
{bookmarks.length === 0 ? (
<div className="rounded-xl border border-dashed py-12 text-center">
<Globe className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="text-muted-foreground"></p>
<Button variant="link" size="sm" onClick={openAdd}>
</Button>
</div>
) : (
Object.entries(grouped).map(([cat, items]) => (
<div key={cat}>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{cat}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => (
<Card key={bm.id} className="group relative">
<CardContent className="flex items-start gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{bm.icon ? (
<img
src={bm.icon}
alt=""
className="size-5 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
"none";
}}
/>
) : (
<Globe className="size-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<a
href={bm.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
>
<span className="truncate">{bm.title}</span>
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</a>
{bm.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{bm.description}
</p>
)}
</div>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-xs"
onClick={() => openEdit(bm)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(bm.id)}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{editing ? "编辑书签" : "添加书签"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="mb-1 block text-sm font-medium">
*
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="例如GitHub"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
*
</label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="简短描述"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium">
</label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="默认"
list="categories"
/>
<datalist id="categories">
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
</label>
<Input
type="number"
value={sortOrder}
onChange={(e) =>
setSortOrder(Number(e.target.value))
}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
URL
</label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="https://example.com/favicon.ico"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button size="sm" onClick={handleSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}