diff options
| -rw-r--r-- | apps/web/app/admin/admin_tools/page.tsx | 19 | ||||
| -rw-r--r-- | apps/web/app/admin/layout.tsx | 7 | ||||
| -rw-r--r-- | apps/web/components/admin/BookmarkDebugger.tsx | 649 | ||||
| -rw-r--r-- | apps/web/components/ui/info-tooltip.tsx | 3 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 37 | ||||
| -rw-r--r-- | packages/trpc/models/assets.ts | 25 | ||||
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 145 | ||||
| -rw-r--r-- | packages/trpc/routers/admin.test.ts | 265 | ||||
| -rw-r--r-- | packages/trpc/routers/admin.ts | 172 |
9 files changed, 1304 insertions, 18 deletions
diff --git a/apps/web/app/admin/admin_tools/page.tsx b/apps/web/app/admin/admin_tools/page.tsx new file mode 100644 index 00000000..e036c755 --- /dev/null +++ b/apps/web/app/admin/admin_tools/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import BookmarkDebugger from "@/components/admin/BookmarkDebugger"; +import { useTranslation } from "@/lib/i18n/server"; + +export async function generateMetadata(): Promise<Metadata> { + // oxlint-disable-next-line rules-of-hooks + const { t } = await useTranslation(); + return { + title: `${t("admin.admin_tools.admin_tools")} | Karakeep`, + }; +} + +export default function AdminToolsPage() { + return ( + <div className="flex flex-col gap-6"> + <BookmarkDebugger /> + </div> + ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 4b589712..03144b78 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -6,7 +6,7 @@ import Sidebar from "@/components/shared/sidebar/Sidebar"; import SidebarLayout from "@/components/shared/sidebar/SidebarLayout"; import { getServerAuthSession } from "@/server/auth"; import { TFunction } from "i18next"; -import { Activity, ArrowLeft, Settings, Users } from "lucide-react"; +import { Activity, ArrowLeft, Settings, Users, Wrench } from "lucide-react"; const adminSidebarItems = ( t: TFunction, @@ -35,6 +35,11 @@ const adminSidebarItems = ( icon: <Settings size={18} />, path: "/admin/background_jobs", }, + { + name: t("admin.admin_tools.admin_tools"), + icon: <Wrench size={18} />, + path: "/admin/admin_tools", + }, ]; export default async function AdminLayout({ diff --git a/apps/web/components/admin/BookmarkDebugger.tsx b/apps/web/components/admin/BookmarkDebugger.tsx new file mode 100644 index 00000000..1628fdcc --- /dev/null +++ b/apps/web/components/admin/BookmarkDebugger.tsx @@ -0,0 +1,649 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminCard } from "@/components/admin/AdminCard"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import InfoTooltip from "@/components/ui/info-tooltip"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { formatBytes } from "@/lib/utils"; +import { formatDistanceToNow } from "date-fns"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Database, + ExternalLink, + FileText, + FileType, + Image as ImageIcon, + Link as LinkIcon, + Loader2, + RefreshCw, + Search, + Sparkles, + Tag, + User, + XCircle, +} from "lucide-react"; +import { parseAsString, useQueryState } from "nuqs"; +import { toast } from "sonner"; + +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +export default function BookmarkDebugger() { + const { t } = useTranslation(); + const [inputValue, setInputValue] = useState(""); + const [bookmarkId, setBookmarkId] = useQueryState( + "bookmarkId", + parseAsString.withDefault(""), + ); + const [showHtmlPreview, setShowHtmlPreview] = useState(false); + + // Sync input value with URL on mount/change + useEffect(() => { + if (bookmarkId) { + setInputValue(bookmarkId); + } + }, [bookmarkId]); + + const { + data: debugInfo, + isLoading, + error, + } = api.admin.getBookmarkDebugInfo.useQuery( + { bookmarkId: bookmarkId }, + { enabled: !!bookmarkId && bookmarkId.length > 0 }, + ); + + const handleLookup = () => { + if (inputValue.trim()) { + setBookmarkId(inputValue.trim()); + } + }; + + const recrawlMutation = api.admin.adminRecrawlBookmark.useMutation({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.recrawl_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }); + + const reindexMutation = api.admin.adminReindexBookmark.useMutation({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.reindex_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }); + + const retagMutation = api.admin.adminRetagBookmark.useMutation({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.retag_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }); + + const resummarizeMutation = api.admin.adminResummarizeBookmark.useMutation({ + onSuccess: () => { + toast.success(t("admin.admin_tools.action_success"), { + description: t("admin.admin_tools.resummarize_queued"), + }); + }, + onError: (error) => { + toast.error(t("admin.admin_tools.action_failed"), { + description: error.message, + }); + }, + }); + + const handleRecrawl = () => { + if (bookmarkId) { + recrawlMutation.mutate({ bookmarkId }); + } + }; + + const handleReindex = () => { + if (bookmarkId) { + reindexMutation.mutate({ bookmarkId }); + } + }; + + const handleRetag = () => { + if (bookmarkId) { + retagMutation.mutate({ bookmarkId }); + } + }; + + const handleResummarize = () => { + if (bookmarkId) { + resummarizeMutation.mutate({ bookmarkId }); + } + }; + + const getStatusBadge = (status: "pending" | "failure" | "success" | null) => { + if (!status) return null; + + const config = { + success: { + variant: "default" as const, + icon: CheckCircle2, + }, + failure: { + variant: "destructive" as const, + icon: XCircle, + }, + pending: { + variant: "secondary" as const, + icon: AlertCircle, + }, + }; + + const { variant, icon: Icon } = config[status]; + + return ( + <Badge variant={variant}> + <Icon className="mr-1 h-3 w-3" /> + {status} + </Badge> + ); + }; + + return ( + <div className="flex flex-col gap-4"> + {/* Input Section */} + <AdminCard> + <div className="mb-3 flex items-center gap-2"> + <Search className="h-5 w-5 text-muted-foreground" /> + <h2 className="text-lg font-semibold"> + {t("admin.admin_tools.bookmark_debugger")} + </h2> + <InfoTooltip className="text-muted-foreground" size={16}> + Some data will be redacted for privacy. + </InfoTooltip> + </div> + <div className="flex gap-2"> + <div className="relative max-w-md flex-1"> + <Database className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> + <Input + placeholder={t("admin.admin_tools.bookmark_id_placeholder")} + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleLookup(); + } + }} + className="pl-9" + /> + </div> + <Button onClick={handleLookup} disabled={!inputValue.trim()}> + <Search className="mr-2 h-4 w-4" /> + {t("admin.admin_tools.lookup")} + </Button> + </div> + </AdminCard> + + {/* Loading State */} + {isLoading && ( + <AdminCard> + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-gray-400" /> + </div> + </AdminCard> + )} + + {/* Error State */} + {!isLoading && error && ( + <AdminCard> + <div className="flex items-center gap-3 rounded-lg border border-destructive/50 bg-destructive/10 p-4"> + <XCircle className="h-5 w-5 flex-shrink-0 text-destructive" /> + <div className="flex-1"> + <h3 className="text-sm font-semibold text-destructive"> + {t("admin.admin_tools.fetch_error")} + </h3> + <p className="mt-1 text-sm text-muted-foreground"> + {error.message} + </p> + </div> + </div> + </AdminCard> + )} + + {/* Debug Info Display */} + {!isLoading && !error && debugInfo && ( + <AdminCard> + <div className="space-y-4"> + {/* Basic Info & Status */} + <div className="grid gap-4 md:grid-cols-2"> + {/* Basic Info */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Database className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.basic_info")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Database className="h-3.5 w-3.5" /> + {t("common.id")} + </span> + <span className="font-mono text-xs">{debugInfo.id}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <FileType className="h-3.5 w-3.5" /> + {t("common.type")} + </span> + <Badge variant="secondary">{debugInfo.type}</Badge> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("common.source")} + </span> + <span>{debugInfo.source || "N/A"}</span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <User className="h-3.5 w-3.5" /> + {t("admin.admin_tools.owner_user_id")} + </span> + <span className="font-mono text-xs"> + {debugInfo.userId} + </span> + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.created_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.createdAt), { + addSuffix: true, + })} + </span> + </div> + {debugInfo.modifiedAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("common.updated_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow(new Date(debugInfo.modifiedAt), { + addSuffix: true, + })} + </span> + </div> + )} + </div> + </div> + + {/* Status */} + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.status")} + </h3> + </div> + <div className="space-y-2.5 text-sm"> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Tag className="h-3.5 w-3.5" /> + {t("admin.admin_tools.tagging_status")} + </span> + {getStatusBadge(debugInfo.taggingStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summarization_status")} + </span> + {getStatusBadge(debugInfo.summarizationStatus)} + </div> + {debugInfo.linkInfo && ( + <> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <RefreshCw className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status")} + </span> + {getStatusBadge(debugInfo.linkInfo.crawlStatus)} + </div> + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawl_status_code")} + </span> + <Badge + variant={ + debugInfo.linkInfo.crawlStatusCode === null || + (debugInfo.linkInfo.crawlStatusCode >= 200 && + debugInfo.linkInfo.crawlStatusCode < 300) + ? "default" + : "destructive" + } + > + {debugInfo.linkInfo.crawlStatusCode} + </Badge> + </div> + {debugInfo.linkInfo.crawledAt && ( + <div className="flex items-center justify-between gap-2"> + <span className="flex items-center gap-1.5 text-muted-foreground"> + <Clock className="h-3.5 w-3.5" /> + {t("admin.admin_tools.crawled_at")} + </span> + <span className="text-xs"> + {formatDistanceToNow( + new Date(debugInfo.linkInfo.crawledAt), + { + addSuffix: true, + }, + )} + </span> + </div> + )} + </> + )} + </div> + </div> + </div> + + {/* Content */} + {(debugInfo.title || + debugInfo.summary || + debugInfo.linkInfo || + debugInfo.textInfo?.sourceUrl || + debugInfo.assetInfo) && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("admin.admin_tools.content")} + </h3> + </div> + <div className="space-y-3 text-sm"> + {debugInfo.title && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <FileText className="h-3.5 w-3.5" /> + {t("common.title")} + </div> + <div className="rounded border bg-background px-3 py-2 font-medium"> + {debugInfo.title} + </div> + </div> + )} + {debugInfo.summary && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <Sparkles className="h-3.5 w-3.5" /> + {t("admin.admin_tools.summary")} + </div> + <div className="rounded border bg-background px-3 py-2"> + {debugInfo.summary} + </div> + </div> + )} + {debugInfo.linkInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.url")} + </div> + <Link + prefetch={false} + href={debugInfo.linkInfo.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.linkInfo.url} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.textInfo?.sourceUrl && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <LinkIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.source_url")} + </div> + <Link + prefetch={false} + href={debugInfo.textInfo.sourceUrl} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 rounded border bg-background px-3 py-2 text-primary hover:underline" + > + <span className="break-all"> + {debugInfo.textInfo.sourceUrl} + </span> + <ExternalLink className="h-3.5 w-3.5 flex-shrink-0" /> + </Link> + </div> + )} + {debugInfo.assetInfo && ( + <div> + <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> + <ImageIcon className="h-3.5 w-3.5" /> + {t("admin.admin_tools.asset_type")} + </div> + <div className="rounded border bg-background px-3 py-2"> + <Badge variant="secondary" className="mb-1"> + {debugInfo.assetInfo.assetType} + </Badge> + {debugInfo.assetInfo.fileName && ( + <div className="mt-1 font-mono text-xs text-muted-foreground"> + {debugInfo.assetInfo.fileName} + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + {/* HTML Preview */} + {debugInfo.linkInfo && debugInfo.linkInfo.htmlContentPreview && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <button + onClick={() => setShowHtmlPreview(!showHtmlPreview)} + className="flex w-full items-center gap-2 text-sm font-semibold hover:opacity-70" + > + {showHtmlPreview ? ( + <ChevronDown className="h-4 w-4 text-muted-foreground" /> + ) : ( + <ChevronRight className="h-4 w-4 text-muted-foreground" /> + )} + <FileText className="h-4 w-4 text-muted-foreground" /> + {t("admin.admin_tools.html_preview")} + </button> + {showHtmlPreview && ( + <pre className="mt-3 max-h-60 overflow-auto rounded-md border bg-muted p-3 text-xs"> + {debugInfo.linkInfo.htmlContentPreview} + </pre> + )} + </div> + )} + + {/* Tags */} + {debugInfo.tags.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <Tag className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.tags")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.tags.length}) + </span> + </h3> + </div> + <div className="flex flex-wrap gap-2"> + {debugInfo.tags.map((tag) => ( + <Badge + key={tag.id} + variant={ + tag.attachedBy === "ai" ? "default" : "secondary" + } + className="gap-1.5" + > + {tag.attachedBy === "ai" && ( + <Sparkles className="h-3 w-3" /> + )} + <span>{tag.name}</span> + </Badge> + ))} + </div> + </div> + )} + + {/* Assets */} + {debugInfo.assets.length > 0 && ( + <div className="rounded-lg border bg-muted/30 p-4"> + <div className="mb-3 flex items-center gap-2"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold"> + {t("common.attachments")}{" "} + <span className="text-muted-foreground"> + ({debugInfo.assets.length}) + </span> + </h3> + </div> + <div className="space-y-2 text-sm"> + {debugInfo.assets.map((asset) => ( + <div + key={asset.id} + className="flex items-center justify-between rounded-md border bg-background p-3" + > + <div className="flex items-center gap-3"> + <ImageIcon className="h-4 w-4 text-muted-foreground" /> + <div> + <Badge variant="secondary" className="text-xs"> + {asset.assetType} + </Badge> + <div className="mt-1 text-xs text-muted-foreground"> + {formatBytes(asset.size)} + </div> + </div> + </div> + {asset.url && ( + <Link + prefetch={false} + href={asset.url} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1.5 text-primary hover:underline" + > + {t("admin.admin_tools.view")} + <ExternalLink className="h-3.5 w-3.5" /> + </Link> + )} + </div> + ))} + </div> + </div> + )} + + {/* Actions */} + <div className="rounded-lg border border-dashed bg-muted/20 p-4"> + <div className="mb-3 flex items-center gap-2"> + <RefreshCw className="h-4 w-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold">{t("common.actions")}</h3> + </div> + <div className="flex flex-wrap gap-2"> + <Button + onClick={handleRecrawl} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + recrawlMutation.isPending + } + size="sm" + variant="outline" + > + {recrawlMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <RefreshCw className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.recrawl")} + </Button> + <Button + onClick={handleReindex} + disabled={reindexMutation.isPending} + size="sm" + variant="outline" + > + {reindexMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Search className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.reindex")} + </Button> + <Button + onClick={handleRetag} + disabled={retagMutation.isPending} + size="sm" + variant="outline" + > + {retagMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Tag className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.retag")} + </Button> + <Button + onClick={handleResummarize} + disabled={ + debugInfo.type !== BookmarkTypes.LINK || + resummarizeMutation.isPending + } + size="sm" + variant="outline" + > + {resummarizeMutation.isPending ? ( + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + ) : ( + <Sparkles className="mr-2 h-4 w-4" /> + )} + {t("admin.admin_tools.resummarize")} + </Button> + </div> + </div> + </div> + </AdminCard> + )} + </div> + ); +} diff --git a/apps/web/components/ui/info-tooltip.tsx b/apps/web/components/ui/info-tooltip.tsx index 4dd97199..9d525983 100644 --- a/apps/web/components/ui/info-tooltip.tsx +++ b/apps/web/components/ui/info-tooltip.tsx @@ -22,8 +22,7 @@ export default function InfoTooltip({ <TooltipTrigger asChild> {variant === "tip" ? ( <Info - color="#494949" - className={cn("z-10 cursor-pointer", className)} + className={cn("z-10 cursor-pointer text-[#494949]", className)} size={size} /> ) : ( diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 1817db81..315564d7 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -1,6 +1,7 @@ { "common": { "default": "Default", + "id": "ID", "url": "URL", "name": "Name", "email": "Email", @@ -583,6 +584,42 @@ "local_user": "Local User", "confirm_password": "Confirm Password", "unlimited": "Unlimited" + }, + "admin_tools": { + "admin_tools": "Admin Tools", + "bookmark_debugger": "Bookmark Debugger", + "bookmark_id": "Bookmark ID", + "bookmark_id_placeholder": "Enter bookmark ID", + "lookup": "Lookup", + "debug_info": "Debug Information", + "basic_info": "Basic Information", + "status": "Status", + "content": "Content", + "html_preview": "HTML Preview (First 1000 chars)", + "summary": "Summary", + "url": "URL", + "source_url": "Source URL", + "asset_type": "Asset Type", + "file_name": "File Name", + "owner_user_id": "Owner User ID", + "tagging_status": "Tagging Status", + "summarization_status": "Summarization Status", + "crawl_status": "Crawl Status", + "crawl_status_code": "HTTP Status Code", + "crawled_at": "Crawled At", + "recrawl": "Re-crawl", + "reindex": "Re-index", + "retag": "Re-tag", + "resummarize": "Re-summarize", + "bookmark_not_found": "Bookmark not found", + "action_success": "Action completed successfully", + "action_failed": "Action failed", + "recrawl_queued": "Re-crawl job has been queued", + "reindex_queued": "Re-index job has been queued", + "retag_queued": "Re-tag job has been queued", + "resummarize_queued": "Re-summarize job has been queued", + "view": "View", + "fetch_error": "Error fetching bookmark" } }, "options": { diff --git a/packages/trpc/models/assets.ts b/packages/trpc/models/assets.ts index ad114341..f97cfffb 100644 --- a/packages/trpc/models/assets.ts +++ b/packages/trpc/models/assets.ts @@ -4,7 +4,11 @@ import { z } from "zod"; import { assets } from "@karakeep/db/schema"; import { deleteAsset } from "@karakeep/shared/assetdb"; +import serverConfig from "@karakeep/shared/config"; +import { createSignedToken } from "@karakeep/shared/signedTokens"; +import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; import { zAssetTypesSchema } from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; import { AuthedContext } from ".."; import { @@ -254,4 +258,25 @@ export class Asset { }); } } + + getUrl() { + return getAssetUrl(this.asset.id); + } + + static getPublicSignedAssetUrl( + assetId: string, + assetOwnerId: string, + expireAt: number, + ) { + const payload: z.infer<typeof zAssetSignedTokenSchema> = { + assetId, + userId: assetOwnerId, + }; + const signedToken = createSignedToken( + payload, + serverConfig.signingSecret(), + expireAt, + ); + return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`; + } } diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts index 0700246f..c8cd1f00 100644 --- a/packages/trpc/models/bookmarks.ts +++ b/packages/trpc/models/bookmarks.ts @@ -31,12 +31,7 @@ import { } from "@karakeep/db/schema"; import { SearchIndexingQueue, triggerWebhook } from "@karakeep/shared-server"; import { deleteAsset, readAsset } from "@karakeep/shared/assetdb"; -import serverConfig from "@karakeep/shared/config"; -import { - createSignedToken, - getAlignedExpiry, -} from "@karakeep/shared/signedTokens"; -import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets"; +import { getAlignedExpiry } from "@karakeep/shared/signedTokens"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, @@ -55,6 +50,7 @@ import { htmlToPlainText } from "@karakeep/shared/utils/htmlUtils"; import { AuthedContext } from ".."; import { mapDBAssetTypeToUserType } from "../lib/attachments"; +import { Asset } from "./assets"; import { List } from "./lists"; async function dummyDrizzleReturnType() { @@ -271,6 +267,130 @@ export class Bookmark extends BareBookmark { return new Bookmark(ctx, data); } + static async buildDebugInfo(ctx: AuthedContext, bookmarkId: string) { + // Verify the user is an admin + if (ctx.user.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin access required", + }); + } + + const PRIVACY_REDACTED_ASSET_TYPES = new Set<AssetTypes>([ + AssetTypes.USER_UPLOADED, + AssetTypes.BOOKMARK_ASSET, + ]); + + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, bookmarkId), + with: { + link: true, + text: true, + asset: true, + tagsOnBookmarks: { + with: { + tag: true, + }, + }, + assets: true, + }, + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + // Build link info + let linkInfo = null; + if (bookmark.link) { + const htmlContentPreview = await (async () => { + try { + const content = await Bookmark.getBookmarkHtmlContent( + bookmark.link!, + bookmark.userId, + ); + return content ? content.substring(0, 1000) : null; + } catch { + return null; + } + })(); + + linkInfo = { + url: bookmark.link.url, + crawlStatus: bookmark.link.crawlStatus ?? "pending", + crawlStatusCode: bookmark.link.crawlStatusCode, + crawledAt: bookmark.link.crawledAt, + hasHtmlContent: !!bookmark.link.htmlContent, + hasContentAsset: !!bookmark.link.contentAssetId, + htmlContentPreview, + }; + } + + // Build text info + let textInfo = null; + if (bookmark.text) { + textInfo = { + hasText: !!bookmark.text.text, + sourceUrl: bookmark.text.sourceUrl, + }; + } + + // Build asset info + let assetInfo = null; + if (bookmark.asset) { + assetInfo = { + assetType: bookmark.asset.assetType, + hasContent: !!bookmark.asset.content, + fileName: bookmark.asset.fileName, + }; + } + + // Build tags + const tags = bookmark.tagsOnBookmarks.map((t) => ({ + id: t.tag.id, + name: t.tag.name, + attachedBy: t.attachedBy, + })); + + // Build assets list with signed URLs (exclude userUploaded) + const assetsWithUrls = bookmark.assets.map((a) => { + // Generate signed token with 10 mins expiry + const expiresAt = Date.now() + 10 * 60 * 1000; // 10 mins + // Exclude userUploaded assets for privacy reasons + const url = !PRIVACY_REDACTED_ASSET_TYPES.has(a.assetType) + ? Asset.getPublicSignedAssetUrl(a.id, bookmark.userId, expiresAt) + : null; + + return { + id: a.id, + assetType: a.assetType, + size: a.size, + url, + }; + }); + + return { + id: bookmark.id, + type: bookmark.type, + source: bookmark.source, + createdAt: bookmark.createdAt, + modifiedAt: bookmark.modifiedAt, + title: bookmark.title, + summary: bookmark.summary, + taggingStatus: bookmark.taggingStatus, + summarizationStatus: bookmark.summarizationStatus, + userId: bookmark.userId, + linkInfo, + textInfo, + assetInfo, + tags, + assets: assetsWithUrls, + }; + } + static async loadMulti( ctx: AuthedContext, input: z.infer<typeof zGetBookmarksRequestSchema>, @@ -641,17 +761,12 @@ export class Bookmark extends BareBookmark { asPublicBookmark(): ZPublicBookmark { const getPublicSignedAssetUrl = (assetId: string) => { - const payload: z.infer<typeof zAssetSignedTokenSchema> = { + // Tokens will expire in 1 hour and will have a grace period of 15mins + return Asset.getPublicSignedAssetUrl( assetId, - userId: this.ctx.user.id, - }; - const signedToken = createSignedToken( - payload, - serverConfig.signingSecret(), - // Tokens will expire in 1 hour and will have a grace period of 15mins - getAlignedExpiry(/* interval */ 3600, /* grace */ 900), + this.bookmark.userId, + getAlignedExpiry(3600, 900), ); - return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`; }; const getContent = ( content: ZBookmarkContent, diff --git a/packages/trpc/routers/admin.test.ts b/packages/trpc/routers/admin.test.ts new file mode 100644 index 00000000..2f80d9c0 --- /dev/null +++ b/packages/trpc/routers/admin.test.ts @@ -0,0 +1,265 @@ +import { eq } from "drizzle-orm"; +import { assert, beforeEach, describe, expect, test } from "vitest"; + +import { bookmarkLinks, users } from "@karakeep/db/schema"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { buildTestContext, getApiCaller } from "../testUtils"; + +beforeEach<CustomTestContext>(async (context) => { + const testContext = await buildTestContext(true); + Object.assign(context, testContext); +}); + +describe("Admin Routes", () => { + describe("getBookmarkDebugInfo", () => { + test<CustomTestContext>("admin can access bookmark debug info for link bookmark", async ({ + apiCallers, + db, + }) => { + // Create an admin user + const adminUser = await db + .insert(users) + .values({ + name: "Admin User", + email: "admin@test.com", + role: "admin", + }) + .returning(); + const adminApi = getApiCaller( + db, + adminUser[0].id, + adminUser[0].email, + "admin", + ); + + // Create a bookmark as a regular user + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Update the bookmark link with some metadata + await db + .update(bookmarkLinks) + .set({ + crawlStatus: "success", + crawlStatusCode: 200, + crawledAt: new Date(), + htmlContent: "<html><body>Test content</body></html>", + title: "Test Title", + description: "Test Description", + }) + .where(eq(bookmarkLinks.id, bookmark.id)); + + // Admin should be able to access debug info + const debugInfo = await adminApi.admin.getBookmarkDebugInfo({ + bookmarkId: bookmark.id, + }); + + expect(debugInfo.id).toEqual(bookmark.id); + expect(debugInfo.type).toEqual(BookmarkTypes.LINK); + expect(debugInfo.linkInfo).toBeDefined(); + assert(debugInfo.linkInfo); + expect(debugInfo.linkInfo.url).toEqual("https://example.com"); + expect(debugInfo.linkInfo.crawlStatus).toEqual("success"); + expect(debugInfo.linkInfo.crawlStatusCode).toEqual(200); + expect(debugInfo.linkInfo.hasHtmlContent).toEqual(true); + expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined(); + expect(debugInfo.linkInfo.htmlContentPreview).toContain("Test content"); + }); + + test<CustomTestContext>("admin can access bookmark debug info for text bookmark", async ({ + apiCallers, + db, + }) => { + // Create an admin user + const adminUser = await db + .insert(users) + .values({ + name: "Admin User", + email: "admin@test.com", + role: "admin", + }) + .returning(); + const adminApi = getApiCaller( + db, + adminUser[0].id, + adminUser[0].email, + "admin", + ); + + // Create a text bookmark + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + text: "This is a test text bookmark", + type: BookmarkTypes.TEXT, + }); + + // Admin should be able to access debug info + const debugInfo = await adminApi.admin.getBookmarkDebugInfo({ + bookmarkId: bookmark.id, + }); + + expect(debugInfo.id).toEqual(bookmark.id); + expect(debugInfo.type).toEqual(BookmarkTypes.TEXT); + expect(debugInfo.textInfo).toBeDefined(); + assert(debugInfo.textInfo); + expect(debugInfo.textInfo.hasText).toEqual(true); + }); + + test<CustomTestContext>("admin can see bookmark tags in debug info", async ({ + apiCallers, + db, + }) => { + // Create an admin user + const adminUser = await db + .insert(users) + .values({ + name: "Admin User", + email: "admin@test.com", + role: "admin", + }) + .returning(); + const adminApi = getApiCaller( + db, + adminUser[0].id, + adminUser[0].email, + "admin", + ); + + // Create a bookmark with tags + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Add tags to the bookmark + await apiCallers[0].bookmarks.updateTags({ + bookmarkId: bookmark.id, + attach: [{ tagName: "test-tag-1" }, { tagName: "test-tag-2" }], + detach: [], + }); + + // Admin should be able to see tags in debug info + const debugInfo = await adminApi.admin.getBookmarkDebugInfo({ + bookmarkId: bookmark.id, + }); + + expect(debugInfo.tags).toHaveLength(2); + expect(debugInfo.tags.map((t) => t.name).sort()).toEqual([ + "test-tag-1", + "test-tag-2", + ]); + expect(debugInfo.tags[0].attachedBy).toEqual("human"); + }); + + test<CustomTestContext>("non-admin user cannot access bookmark debug info", async ({ + apiCallers, + }) => { + // Create a bookmark + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Non-admin user should not be able to access debug info + // The admin procedure itself will throw FORBIDDEN + await expect(() => + apiCallers[0].admin.getBookmarkDebugInfo({ bookmarkId: bookmark.id }), + ).rejects.toThrow(/FORBIDDEN/); + }); + + test<CustomTestContext>("debug info includes asset URLs with signed tokens", async ({ + apiCallers, + db, + }) => { + // Create an admin user + const adminUser = await db + .insert(users) + .values({ + name: "Admin User", + email: "admin@test.com", + role: "admin", + }) + .returning(); + const adminApi = getApiCaller( + db, + adminUser[0].id, + adminUser[0].email, + "admin", + ); + + // Create a bookmark + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Get debug info + const debugInfo = await adminApi.admin.getBookmarkDebugInfo({ + bookmarkId: bookmark.id, + }); + + // Check that assets array is present + expect(debugInfo.assets).toBeDefined(); + expect(Array.isArray(debugInfo.assets)).toBe(true); + + // If there are assets, check that they have signed URLs + if (debugInfo.assets.length > 0) { + const asset = debugInfo.assets[0]; + expect(asset.url).toBeDefined(); + expect(asset.url).toContain("/api/public/assets/"); + expect(asset.url).toContain("token="); + } + }); + + test<CustomTestContext>("debug info truncates HTML content preview", async ({ + apiCallers, + db, + }) => { + // Create an admin user + const adminUser = await db + .insert(users) + .values({ + name: "Admin User", + email: "admin@test.com", + role: "admin", + }) + .returning(); + const adminApi = getApiCaller( + db, + adminUser[0].id, + adminUser[0].email, + "admin", + ); + + // Create a bookmark + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + // Create a large HTML content + const largeContent = "<html><body>" + "x".repeat(2000) + "</body></html>"; + await db + .update(bookmarkLinks) + .set({ + htmlContent: largeContent, + }) + .where(eq(bookmarkLinks.id, bookmark.id)); + + // Get debug info + const debugInfo = await adminApi.admin.getBookmarkDebugInfo({ + bookmarkId: bookmark.id, + }); + + // Check that HTML preview is truncated to 1000 characters + assert(debugInfo.linkInfo); + expect(debugInfo.linkInfo.htmlContentPreview).toBeDefined(); + expect(debugInfo.linkInfo.htmlContentPreview!.length).toBeLessThanOrEqual( + 1000, + ); + }); + }); +}); diff --git a/packages/trpc/routers/admin.ts b/packages/trpc/routers/admin.ts index 44a51cad..3236529d 100644 --- a/packages/trpc/routers/admin.ts +++ b/packages/trpc/routers/admin.ts @@ -17,6 +17,7 @@ import { zAdminMaintenanceTaskSchema, } from "@karakeep/shared-server"; import serverConfig from "@karakeep/shared/config"; +import logger from "@karakeep/shared/logger"; import { PluginManager, PluginType } from "@karakeep/shared/plugins"; import { getSearchClient } from "@karakeep/shared/search"; import { @@ -24,9 +25,11 @@ import { updateUserSchema, zAdminCreateUserSchema, } from "@karakeep/shared/types/admin"; +import { BookmarkTypes } from "@karakeep/shared/types/bookmarks"; import { generatePasswordSalt, hashPassword } from "../auth"; import { adminProcedure, router } from "../index"; +import { Bookmark } from "../models/bookmarks"; import { User } from "../models/users"; export const adminAppRouter = router({ @@ -558,4 +561,173 @@ export const adminAppRouter = router({ queue: queueStatus, }; }), + getBookmarkDebugInfo: adminProcedure + .input(z.object({ bookmarkId: z.string() })) + .output( + z.object({ + id: z.string(), + type: z.enum([ + BookmarkTypes.LINK, + BookmarkTypes.TEXT, + BookmarkTypes.ASSET, + ]), + source: z + .enum([ + "api", + "web", + "extension", + "cli", + "mobile", + "singlefile", + "rss", + "import", + ]) + .nullable(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), + title: z.string().nullable(), + summary: z.string().nullable(), + taggingStatus: z.enum(["pending", "failure", "success"]).nullable(), + summarizationStatus: z + .enum(["pending", "failure", "success"]) + .nullable(), + userId: z.string(), + linkInfo: z + .object({ + url: z.string(), + crawlStatus: z.enum(["pending", "failure", "success"]), + crawlStatusCode: z.number().nullable(), + crawledAt: z.date().nullable(), + hasHtmlContent: z.boolean(), + hasContentAsset: z.boolean(), + htmlContentPreview: z.string().nullable(), + }) + .nullable(), + textInfo: z + .object({ + hasText: z.boolean(), + sourceUrl: z.string().nullable(), + }) + .nullable(), + assetInfo: z + .object({ + assetType: z.enum(["image", "pdf"]), + hasContent: z.boolean(), + fileName: z.string().nullable(), + }) + .nullable(), + tags: z.array( + z.object({ + id: z.string(), + name: z.string(), + attachedBy: z.enum(["ai", "human"]), + }), + ), + assets: z.array( + z.object({ + id: z.string(), + assetType: z.string(), + size: z.number(), + url: z.string().nullable(), + }), + ), + }), + ) + .query(async ({ input, ctx }) => { + logger.info( + `[admin] Admin ${ctx.user.id} accessed debug info for bookmark ${input.bookmarkId}`, + ); + + return await Bookmark.buildDebugInfo(ctx, input.bookmarkId); + }), + adminRecrawlBookmark: adminProcedure + .input(z.object({ bookmarkId: z.string() })) + .mutation(async ({ input, ctx }) => { + // Verify bookmark exists and is a link + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, input.bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (bookmark.type !== BookmarkTypes.LINK) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only link bookmarks can be recrawled", + }); + } + + await LinkCrawlerQueue.enqueue({ + bookmarkId: input.bookmarkId, + }); + }), + adminReindexBookmark: adminProcedure + .input(z.object({ bookmarkId: z.string() })) + .mutation(async ({ input, ctx }) => { + // Verify bookmark exists + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, input.bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + await triggerSearchReindex(input.bookmarkId); + }), + adminRetagBookmark: adminProcedure + .input(z.object({ bookmarkId: z.string() })) + .mutation(async ({ input, ctx }) => { + // Verify bookmark exists + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, input.bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + await OpenAIQueue.enqueue({ + bookmarkId: input.bookmarkId, + type: "tag", + }); + }), + adminResummarizeBookmark: adminProcedure + .input(z.object({ bookmarkId: z.string() })) + .mutation(async ({ input, ctx }) => { + // Verify bookmark exists and is a link + const bookmark = await ctx.db.query.bookmarks.findFirst({ + where: eq(bookmarks.id, input.bookmarkId), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found", + }); + } + + if (bookmark.type !== BookmarkTypes.LINK) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Only link bookmarks can be summarized", + }); + } + + await OpenAIQueue.enqueue({ + bookmarkId: input.bookmarkId, + type: "summarize", + }); + }), }); |
