aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/public/layout.tsx16
-rw-r--r--apps/web/app/public/lists/[listId]/not-found.tsx18
-rw-r--r--apps/web/app/public/lists/[listId]/page.tsx84
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx13
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx8
-rw-r--r--apps/web/components/dashboard/lists/PublicListLink.tsx67
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx107
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx4
-rw-r--r--apps/web/components/public/lists/PublicBookmarkGrid.tsx247
-rw-r--r--apps/web/components/public/lists/PublicListHeader.tsx17
-rw-r--r--apps/web/components/ui/copy-button.tsx41
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--packages/api/index.ts4
-rw-r--r--packages/api/routes/assets.ts55
-rw-r--r--packages/api/routes/public.ts47
-rw-r--r--packages/api/routes/rss.ts4
-rw-r--r--packages/api/utils/assets.ts57
-rw-r--r--packages/db/drizzle/0051_public_lists.sql1
-rw-r--r--packages/db/drizzle/meta/0051_snapshot.json2029
-rw-r--r--packages/db/drizzle/meta/_journal.json9
-rw-r--r--packages/db/schema.ts9
-rw-r--r--packages/e2e_tests/docker-compose.yml1
-rw-r--r--packages/e2e_tests/tests/api/public.test.ts322
-rw-r--r--packages/e2e_tests/vitest.config.ts3
-rw-r--r--packages/open-api/karakeep-openapi-spec.json9
-rw-r--r--packages/shared/config.ts7
-rw-r--r--packages/shared/signedTokens.ts71
-rw-r--r--packages/shared/types/assets.ts6
-rw-r--r--packages/shared/types/bookmarks.ts2
-rw-r--r--packages/shared/types/lists.ts2
-rw-r--r--packages/shared/utils/bookmarkUtils.ts22
-rw-r--r--packages/trpc/models/bookmarks.ts59
-rw-r--r--packages/trpc/models/lists.ts22
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/publicBookmarks.ts49
36 files changed, 3286 insertions, 147 deletions
diff --git a/apps/web/app/public/layout.tsx b/apps/web/app/public/layout.tsx
new file mode 100644
index 00000000..4203c44c
--- /dev/null
+++ b/apps/web/app/public/layout.tsx
@@ -0,0 +1,16 @@
+import KarakeepLogo from "@/components/KarakeepIcon";
+
+export default function PublicLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <div className="h-screen flex-col overflow-y-auto bg-muted">
+ <header className="sticky left-0 right-0 top-0 z-50 flex h-16 items-center justify-between overflow-x-auto overflow-y-hidden bg-background p-4 shadow">
+ <KarakeepLogo height={38} />
+ </header>
+ <main className="container mx-3 mt-3 flex-1">{children}</main>
+ </div>
+ );
+}
diff --git a/apps/web/app/public/lists/[listId]/not-found.tsx b/apps/web/app/public/lists/[listId]/not-found.tsx
new file mode 100644
index 00000000..a6fd71dc
--- /dev/null
+++ b/apps/web/app/public/lists/[listId]/not-found.tsx
@@ -0,0 +1,18 @@
+import { X } from "lucide-react";
+
+export default function PublicListPageNotFound() {
+ return (
+ <div className="mx-auto flex max-w-md flex-1 flex-col items-center justify-center px-4 py-16 text-center">
+ <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
+ <X className="h-12 w-12 text-gray-300" strokeWidth={1.5} />
+ </div>
+ <h1 className="mb-3 text-2xl font-semibold text-gray-800">
+ List not found
+ </h1>
+ <p className="text-center text-gray-500">
+ The list you&apos;re looking for doesn&apos;t exist or may have been
+ removed.
+ </p>
+ </div>
+ );
+}
diff --git a/apps/web/app/public/lists/[listId]/page.tsx b/apps/web/app/public/lists/[listId]/page.tsx
new file mode 100644
index 00000000..c0495b9f
--- /dev/null
+++ b/apps/web/app/public/lists/[listId]/page.tsx
@@ -0,0 +1,84 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import NoBookmarksBanner from "@/components/dashboard/bookmarks/NoBookmarksBanner";
+import PublicBookmarkGrid from "@/components/public/lists/PublicBookmarkGrid";
+import PublicListHeader from "@/components/public/lists/PublicListHeader";
+import { Separator } from "@/components/ui/separator";
+import { api } from "@/server/api/client";
+import { TRPCError } from "@trpc/server";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: { listId: string };
+}): Promise<Metadata> {
+ // TODO: Don't load the entire list, just create an endpoint to get the list name
+ try {
+ const resp = await api.publicBookmarks.getPublicBookmarksInList({
+ listId: params.listId,
+ });
+ return {
+ title: `${resp.list.name} - Karakeep`,
+ };
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "NOT_FOUND") {
+ notFound();
+ }
+ }
+ return {
+ title: "Karakeep",
+ };
+}
+
+export default async function PublicListPage({
+ params,
+}: {
+ params: { listId: string };
+}) {
+ try {
+ const { list, bookmarks, nextCursor } =
+ await api.publicBookmarks.getPublicBookmarksInList({
+ listId: params.listId,
+ });
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex items-center gap-2">
+ <span className="text-2xl">
+ {list.icon} {list.name}
+ {list.description && (
+ <span className="mx-2 text-lg text-gray-400">
+ {`(${list.description})`}
+ </span>
+ )}
+ </span>
+ </div>
+ <Separator />
+ <PublicListHeader
+ list={{
+ id: params.listId,
+ numItems: list.numItems,
+ }}
+ />
+ {list.numItems > 0 ? (
+ <PublicBookmarkGrid
+ list={{
+ id: params.listId,
+ name: list.name,
+ description: list.description,
+ icon: list.icon,
+ numItems: list.numItems,
+ }}
+ bookmarks={bookmarks}
+ nextCursor={nextCursor}
+ />
+ ) : (
+ <NoBookmarksBanner />
+ )}
+ </div>
+ );
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "NOT_FOUND") {
+ notFound();
+ }
+ }
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx
new file mode 100644
index 00000000..a3e5d3b3
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx
@@ -0,0 +1,8 @@
+import dayjs from "dayjs";
+
+export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) {
+ const createdAt = dayjs(prop.createdAt);
+ const oneYearAgo = dayjs().subtract(1, "year");
+ const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY";
+ return createdAt.format(formatString);
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
index 6f55ca00..4b511a3c 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
@@ -8,7 +8,6 @@ import {
useBookmarkLayout,
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
-import dayjs from "dayjs";
import { Check, Image as ImageIcon, NotebookPen } from "lucide-react";
import { useTheme } from "next-themes";
@@ -17,6 +16,7 @@ import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils";
import BookmarkActionBar from "./BookmarkActionBar";
+import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt";
import TagList from "./TagList";
interface Props {
@@ -30,13 +30,6 @@ interface Props {
wrapTags: boolean;
}
-function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) {
- const createdAt = dayjs(bookmark.createdAt);
- const oneYearAgo = dayjs().subtract(1, "year");
- const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY";
- return createdAt.format(formatString);
-}
-
function BottomRow({
footer,
bookmark,
@@ -52,7 +45,7 @@ function BottomRow({
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
>
- <BookmarkFormattedCreatedAt bookmark={bookmark} />
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
@@ -239,7 +232,7 @@ function CompactView({ bookmark, title, footer, className }: Props) {
suppressHydrationWarning
className="shrink-0 gap-2 text-gray-500"
>
- <BookmarkFormattedCreatedAt bookmark={bookmark} />
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
index debd5ad9..82e483a9 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
@@ -2,14 +2,18 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
import { toast } from "@/components/ui/use-toast";
-import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
export function BookmarkMarkdownComponent({
children: bookmark,
readOnly = true,
}: {
- children: ZBookmarkTypeText;
+ children: {
+ id: string;
+ content: {
+ text: string;
+ };
+ };
readOnly?: boolean;
}) {
const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmark({
diff --git a/apps/web/components/dashboard/lists/PublicListLink.tsx b/apps/web/components/dashboard/lists/PublicListLink.tsx
new file mode 100644
index 00000000..9cd1f795
--- /dev/null
+++ b/apps/web/components/dashboard/lists/PublicListLink.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { CopyBtnV2 } from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { useClientConfig } from "@/lib/clientConfig";
+import { useTranslation } from "react-i18next";
+
+import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists";
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export default function PublicListLink({ list }: { list: ZBookmarkList }) {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+
+ const { mutate: editList, isPending: isLoading } = useEditBookmarkList();
+
+ const publicListUrl = `${clientConfig.publicUrl}/public/lists/${list.id}`;
+ const isPublic = list.public;
+
+ return (
+ <>
+ {/* Public List Toggle */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <Label htmlFor="public-toggle" className="text-sm font-medium">
+ {t("lists.public_list.title")}
+ </Label>
+ <p className="text-xs text-muted-foreground">
+ {t("lists.public_list.description")}
+ </p>
+ </div>
+ <Switch
+ id="public-toggle"
+ checked={isPublic}
+ disabled={isLoading || !!clientConfig.demoMode}
+ onCheckedChange={(checked) => {
+ editList({
+ listId: list.id,
+ public: checked,
+ });
+ }}
+ />
+ </div>
+
+ {/* Share URL - only show when public */}
+ {isPublic && (
+ <>
+ <div className="space-y-3">
+ <Label className="text-sm font-medium">
+ {t("lists.public_list.share_link")}
+ </Label>
+ <div className="flex items-center space-x-2">
+ <Input
+ value={publicListUrl}
+ readOnly
+ className="flex-1 text-sm"
+ />
+ <CopyBtnV2 getStringToCopy={() => publicListUrl} />
+ </div>
+ </div>
+ </>
+ )}
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx
index 152a3fe4..1be48681 100644
--- a/apps/web/components/dashboard/lists/RssLink.tsx
+++ b/apps/web/components/dashboard/lists/RssLink.tsx
@@ -1,14 +1,14 @@
"use client";
import { useMemo } from "react";
-import { Badge } from "@/components/ui/badge";
-import { Button, buttonVariants } from "@/components/ui/button";
-import CopyBtn from "@/components/ui/copy-button";
+import { Button } from "@/components/ui/button";
+import { CopyBtnV2 } from "@/components/ui/copy-button";
import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
-import { cn } from "@/lib/utils";
-import { Loader2, RotateCcw, Rss, Trash2 } from "lucide-react";
+import { Loader2, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
export default function RssLink({ listId }: { listId: string }) {
@@ -38,77 +38,58 @@ export default function RssLink({ listId }: { listId: string }) {
return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`;
}, [rssToken]);
- const isLoading = isRegenPending || isClearPending || isTokenLoading;
+ const rssEnabled = rssUrl !== null;
return (
- <div className="flex items-center gap-3 rounded-lg border bg-white p-3">
- <Badge variant="outline" className="text-xs">
- RSS
- </Badge>
- {!rssUrl ? (
- <div className="flex items-center gap-2">
- <Button
- size="sm"
- variant="outline"
- onClick={() => regenRssToken({ listId })}
- disabled={isLoading}
- >
- {isLoading ? (
- <Loader2 className="h-3 w-3 animate-spin" />
- ) : (
- <span className="flex items-center">
- <Rss className="mr-2 h-4 w-4 flex-shrink-0 text-orange-500" />
- {t("lists.generate_rss_feed")}
- </span>
- )}
- </Button>
+ <>
+ {/* RSS Feed Toggle */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <Label htmlFor="rss-toggle" className="text-sm font-medium">
+ {t("lists.rss.title")}
+ </Label>
+ <p className="text-xs text-muted-foreground">
+ {t("lists.rss.description")}
+ </p>
</div>
- ) : (
- <div className="flex min-w-0 flex-1 items-center gap-2">
- <Input
- value={rssUrl}
- readOnly
- className="h-8 min-w-0 flex-1 font-mono text-xs"
- />
- <div className="flex flex-shrink-0 gap-1">
- <CopyBtn
- getStringToCopy={() => {
- return rssUrl;
- }}
- className={cn(
- buttonVariants({ variant: "outline", size: "sm" }),
- "h-8 w-8 p-0",
- )}
- />
+ <Switch
+ id="rss-toggle"
+ checked={rssEnabled}
+ onCheckedChange={(checked) =>
+ checked ? regenRssToken({ listId }) : clearRssToken({ listId })
+ }
+ disabled={
+ isTokenLoading ||
+ isClearPending ||
+ isRegenPending ||
+ !!clientConfig.demoMode
+ }
+ />
+ </div>
+ {/* RSS URL - only show when RSS is enabled */}
+ {rssEnabled && (
+ <div className="space-y-3">
+ <Label className="text-sm font-medium">
+ {t("lists.rss.feed_url")}
+ </Label>
+ <div className="flex items-center space-x-2">
+ <Input value={rssUrl} readOnly className="flex-1 text-sm" />
+ <CopyBtnV2 getStringToCopy={() => rssUrl} />
<Button
variant="outline"
size="sm"
onClick={() => regenRssToken({ listId })}
- disabled={isLoading}
- className="h-8 w-8 p-0"
- >
- {isLoading ? (
- <Loader2 className="h-3 w-3 animate-spin" />
- ) : (
- <RotateCcw className="h-3 w-3" />
- )}
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={() => clearRssToken({ listId })}
- disabled={isLoading}
- className="h-8 w-8 p-0 text-destructive hover:text-destructive"
+ disabled={isRegenPending}
>
- {isLoading ? (
- <Loader2 className="h-3 w-3 animate-spin" />
+ {isRegenPending ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
) : (
- <Trash2 className="h-3 w-3" />
+ <RotateCcw className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
- </div>
+ </>
);
}
diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx
index 5c7b060e..16668e67 100644
--- a/apps/web/components/dashboard/lists/ShareListModal.tsx
+++ b/apps/web/components/dashboard/lists/ShareListModal.tsx
@@ -14,6 +14,7 @@ import { DialogDescription } from "@radix-ui/react-dialog";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
+import PublicListLink from "./PublicListLink";
import RssLink from "./RssLink";
export function ShareListModal({
@@ -52,7 +53,8 @@ export function ShareListModal({
<DialogHeader>
<DialogTitle>{t("lists.share_list")}</DialogTitle>
</DialogHeader>
- <DialogDescription className="mt-4 flex flex-col gap-2">
+ <DialogDescription className="mt-4 space-y-6">
+ <PublicListLink list={list} />
<RssLink listId={list.id} />
</DialogDescription>
<DialogFooter className="sm:justify-end">
diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx
new file mode 100644
index 00000000..038ac3ae
--- /dev/null
+++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import BookmarkFormattedCreatedAt from "@/components/dashboard/bookmarks/BookmarkFormattedCreatedAt";
+import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent";
+import FooterLinkURL from "@/components/dashboard/bookmarks/FooterLinkURL";
+import { ActionButton } from "@/components/ui/action-button";
+import { badgeVariants } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import tailwindConfig from "@/tailwind.config";
+import { Expand, FileIcon, ImageIcon } from "lucide-react";
+import { useInView } from "react-intersection-observer";
+import Masonry from "react-masonry-css";
+import resolveConfig from "tailwindcss/resolveConfig";
+
+import {
+ BookmarkTypes,
+ ZPublicBookmark,
+} from "@karakeep/shared/types/bookmarks";
+import { ZCursor } from "@karakeep/shared/types/pagination";
+
+function TagPill({ tag }: { tag: string }) {
+ return (
+ <div
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
+ )}
+ key={tag}
+ >
+ {tag}
+ </div>
+ );
+}
+
+function BookmarkCard({ bookmark }: { bookmark: ZPublicBookmark }) {
+ const renderContent = () => {
+ switch (bookmark.content.type) {
+ case BookmarkTypes.LINK:
+ return (
+ <div className="space-y-2">
+ {bookmark.bannerImageUrl && (
+ <div className="aspect-video w-full overflow-hidden rounded bg-gray-100">
+ <Link href={bookmark.content.url} target="_blank">
+ <img
+ src={bookmark.bannerImageUrl}
+ alt={bookmark.title ?? "Link preview"}
+ className="h-full w-full object-cover"
+ />
+ </Link>
+ </div>
+ )}
+ <div className="space-y-2">
+ <Link
+ href={bookmark.content.url}
+ target="_blank"
+ className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900"
+ >
+ {bookmark.title}
+ </Link>
+ </div>
+ </div>
+ );
+
+ case BookmarkTypes.TEXT:
+ return (
+ <div className="space-y-2">
+ {bookmark.title && (
+ <h3 className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900">
+ {bookmark.title}
+ </h3>
+ )}
+ <div className="group relative max-h-64 overflow-hidden">
+ <BookmarkMarkdownComponent readOnly={true}>
+ {{
+ id: bookmark.id,
+ content: {
+ text: bookmark.content.text,
+ },
+ }}
+ </BookmarkMarkdownComponent>
+ <Dialog>
+ <DialogTrigger className="absolute bottom-2 right-2 z-50 h-4 w-4 opacity-0 group-hover:opacity-100">
+ <Expand className="h-4 w-4" />
+ </DialogTrigger>
+ <DialogContent className="max-h-96 max-w-3xl overflow-auto">
+ <BookmarkMarkdownComponent readOnly={true}>
+ {{
+ id: bookmark.id,
+ content: {
+ text: bookmark.content.text,
+ },
+ }}
+ </BookmarkMarkdownComponent>
+ </DialogContent>
+ </Dialog>
+ </div>
+ </div>
+ );
+
+ case BookmarkTypes.ASSET:
+ return (
+ <div className="space-y-2">
+ {bookmark.bannerImageUrl ? (
+ <div className="aspect-video w-full overflow-hidden rounded bg-gray-100">
+ <Link href={bookmark.content.assetUrl}>
+ <img
+ src={bookmark.bannerImageUrl}
+ alt={bookmark.title ?? "Asset preview"}
+ className="h-full w-full object-cover"
+ />
+ </Link>
+ </div>
+ ) : (
+ <div className="flex aspect-video w-full items-center justify-center overflow-hidden rounded bg-gray-100">
+ {bookmark.content.assetType === "image" ? (
+ <ImageIcon className="h-8 w-8 text-gray-400" />
+ ) : (
+ <FileIcon className="h-8 w-8 text-gray-400" />
+ )}
+ </div>
+ )}
+ <div className="space-y-1">
+ <Link
+ href={bookmark.content.assetUrl}
+ target="_blank"
+ className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900"
+ >
+ {bookmark.title}
+ </Link>
+ </div>
+ </div>
+ );
+ }
+ };
+
+ return (
+ <Card className="group mb-3 border-0 shadow-sm transition-all duration-200 hover:shadow-lg">
+ <CardContent className="p-3">
+ {renderContent()}
+
+ {/* Tags */}
+ {bookmark.tags.length > 0 && (
+ <div className="mt-2 flex flex-wrap gap-1">
+ {bookmark.tags.map((tag, index) => (
+ <TagPill key={index} tag={tag} />
+ ))}
+ </div>
+ )}
+
+ {/* Footer */}
+ <div className="mt-3 flex items-center justify-between pt-2">
+ <div className="flex items-center gap-2 text-xs text-gray-500">
+ {bookmark.content.type === BookmarkTypes.LINK && (
+ <>
+ <FooterLinkURL url={bookmark.content.url} />
+ <span>•</span>
+ </>
+ )}
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
+
+function getBreakpointConfig() {
+ const fullConfig = resolveConfig(tailwindConfig);
+
+ const breakpointColumnsObj: { [key: number]: number; default: number } = {
+ default: 3,
+ };
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2;
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1;
+ breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1;
+ return breakpointColumnsObj;
+}
+
+export default function PublicBookmarkGrid({
+ bookmarks: initialBookmarks,
+ nextCursor,
+ list,
+}: {
+ list: {
+ id: string;
+ name: string;
+ description: string | null | undefined;
+ icon: string;
+ numItems: number;
+ };
+ bookmarks: ZPublicBookmark[];
+ nextCursor: ZCursor | null;
+}) {
+ const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView();
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ api.publicBookmarks.getPublicBookmarksInList.useInfiniteQuery(
+ { listId: list.id },
+ {
+ initialData: () => ({
+ pages: [{ bookmarks: initialBookmarks, nextCursor, list }],
+ pageParams: [null],
+ }),
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ refetchOnMount: true,
+ },
+ );
+
+ useEffect(() => {
+ if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [loadMoreButtonInView]);
+
+ const breakpointConfig = useMemo(() => getBreakpointConfig(), []);
+
+ const bookmarks = useMemo(() => {
+ return data.pages.flatMap((b) => b.bookmarks);
+ }, [data]);
+ return (
+ <>
+ <Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
+ {bookmarks.map((bookmark) => (
+ <BookmarkCard key={bookmark.id} bookmark={bookmark} />
+ ))}
+ </Masonry>
+ {hasNextPage && (
+ <div className="flex justify-center">
+ <ActionButton
+ ref={loadMoreRef}
+ ignoreDemoMode={true}
+ loading={isFetchingNextPage}
+ onClick={() => fetchNextPage()}
+ variant="ghost"
+ >
+ Load More
+ </ActionButton>
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/apps/web/components/public/lists/PublicListHeader.tsx b/apps/web/components/public/lists/PublicListHeader.tsx
new file mode 100644
index 00000000..1f016351
--- /dev/null
+++ b/apps/web/components/public/lists/PublicListHeader.tsx
@@ -0,0 +1,17 @@
+export default function PublicListHeader({
+ list,
+}: {
+ list: {
+ id: string;
+ numItems: number;
+ };
+}) {
+ return (
+ <div className="flex w-full justify-between">
+ <span />
+ <p className="text-xs font-light uppercase text-gray-500">
+ {list.numItems} bookmarks
+ </p>
+ </div>
+ );
+}
diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx
index a51ce902..1cb405da 100644
--- a/apps/web/components/ui/copy-button.tsx
+++ b/apps/web/components/ui/copy-button.tsx
@@ -1,6 +1,10 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
import { Check, Copy } from "lucide-react";
+import { Button } from "./button";
+import { toast } from "./use-toast";
+
export default function CopyBtn({
className,
getStringToCopy,
@@ -35,3 +39,38 @@ export default function CopyBtn({
</button>
);
}
+
+export function CopyBtnV2({
+ className,
+ getStringToCopy,
+}: {
+ className?: string;
+ getStringToCopy: () => string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async (url: string) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ toast({
+ description:
+ "Failed to copy link. Browsers only support copying to the clipboard from https pages.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleCopy(getStringToCopy())}
+ className={cn("shrink-0", className)}
+ >
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
+ </Button>
+ );
+}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 39be43f3..3ad4a25e 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -304,7 +304,16 @@
"search_query": "Search Query",
"search_query_help": "Learn more about the search query language.",
"description": "Description (Optional)",
- "generate_rss_feed": "Generate RSS Feed"
+ "rss": {
+ "title": "RSS Feed",
+ "description": "Enable an RSS feed for this list",
+ "feed_url": "RSS Feed URL"
+ },
+ "public_list": {
+ "title": "Public List",
+ "description": "Allow others to view this list",
+ "share_link": "Share Link"
+ }
},
"tags": {
"all_tags": "All Tags",
diff --git a/packages/api/index.ts b/packages/api/index.ts
index a3ba8d42..5147ea37 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -9,6 +9,7 @@ import assets from "./routes/assets";
import bookmarks from "./routes/bookmarks";
import highlights from "./routes/highlights";
import lists from "./routes/lists";
+import publicRoute from "./routes/public";
import rss from "./routes/rss";
import tags from "./routes/tags";
import users from "./routes/users";
@@ -43,6 +44,7 @@ const app = new Hono<{
})
.use(trpcAdapter)
.route("/v1", v1)
- .route("/assets", assets);
+ .route("/assets", assets)
+ .route("/public", publicRoute);
export default app;
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
index de4e384d..9d9a60b3 100644
--- a/packages/api/routes/assets.ts
+++ b/packages/api/routes/assets.ts
@@ -1,18 +1,13 @@
import { zValidator } from "@hono/zod-validator";
import { and, eq } from "drizzle-orm";
import { Hono } from "hono";
-import { stream } from "hono/streaming";
import { z } from "zod";
import { assets } from "@karakeep/db/schema";
-import {
- createAssetReadStream,
- getAssetSize,
- readAssetMetadata,
-} from "@karakeep/shared/assetdb";
import { authMiddleware } from "../middlewares/auth";
-import { toWebReadableStream, uploadAsset } from "../utils/upload";
+import { serveAsset } from "../utils/assets";
+import { uploadAsset } from "../utils/upload";
const app = new Hono()
.use(authMiddleware)
@@ -47,51 +42,7 @@ const app = new Hono()
if (!assetDb) {
return c.json({ error: "Asset not found" }, { status: 404 });
}
-
- const [metadata, size] = await Promise.all([
- readAssetMetadata({
- userId: c.var.ctx.user.id,
- assetId,
- }),
-
- getAssetSize({
- userId: c.var.ctx.user.id,
- assetId,
- }),
- ]);
-
- const range = c.req.header("Range");
- if (range) {
- const parts = range.replace(/bytes=/, "").split("-");
- const start = parseInt(parts[0], 10);
- const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
-
- const fStream = createAssetReadStream({
- userId: c.var.ctx.user.id,
- assetId,
- start,
- end,
- });
- c.status(206); // Partial Content
- c.header("Content-Range", `bytes ${start}-${end}/${size}`);
- c.header("Accept-Ranges", "bytes");
- c.header("Content-Length", (end - start + 1).toString());
- c.header("Content-type", metadata.contentType);
- return stream(c, async (stream) => {
- await stream.pipe(toWebReadableStream(fStream));
- });
- } else {
- const fStream = createAssetReadStream({
- userId: c.var.ctx.user.id,
- assetId,
- });
- c.status(200);
- c.header("Content-Length", size.toString());
- c.header("Content-type", metadata.contentType);
- return stream(c, async (stream) => {
- await stream.pipe(toWebReadableStream(fStream));
- });
- }
+ return await serveAsset(c, assetId, c.var.ctx.user.id);
});
export default app;
diff --git a/packages/api/routes/public.ts b/packages/api/routes/public.ts
new file mode 100644
index 00000000..d17049c4
--- /dev/null
+++ b/packages/api/routes/public.ts
@@ -0,0 +1,47 @@
+import { zValidator } from "@hono/zod-validator";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+import { verifySignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+
+import { unauthedMiddleware } from "../middlewares/auth";
+import { serveAsset } from "../utils/assets";
+
+const app = new Hono().get(
+ "/assets/:assetId",
+ unauthedMiddleware,
+ zValidator(
+ "query",
+ z.object({
+ token: z.string(),
+ }),
+ ),
+ async (c) => {
+ const assetId = c.req.param("assetId");
+ const tokenPayload = verifySignedToken(
+ c.req.valid("query").token,
+ zAssetSignedTokenSchema,
+ );
+ if (!tokenPayload) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ if (tokenPayload.assetId !== assetId) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ const userId = tokenPayload.userId;
+
+ const assetDb = await c.var.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, userId)),
+ });
+
+ if (!assetDb) {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+ return await serveAsset(c, assetId, userId);
+ },
+);
+
+export default app;
diff --git a/packages/api/routes/rss.ts b/packages/api/routes/rss.ts
index 81c9756c..88b943ad 100644
--- a/packages/api/routes/rss.ts
+++ b/packages/api/routes/rss.ts
@@ -28,8 +28,10 @@ const app = new Hono().get(
const searchParams = c.req.valid("query");
const token = searchParams.token;
- const res = await List.getForRss(c.var.ctx, listId, token, {
+ const res = await List.getPublicListContents(c.var.ctx, listId, token, {
limit: searchParams.limit ?? 20,
+ order: "desc",
+ cursor: null,
});
const list = res.list;
diff --git a/packages/api/utils/assets.ts b/packages/api/utils/assets.ts
new file mode 100644
index 00000000..d8a726a6
--- /dev/null
+++ b/packages/api/utils/assets.ts
@@ -0,0 +1,57 @@
+import { Context } from "hono";
+import { stream } from "hono/streaming";
+
+import {
+ createAssetReadStream,
+ getAssetSize,
+ readAssetMetadata,
+} from "@karakeep/shared/assetdb";
+
+import { toWebReadableStream } from "./upload";
+
+export async function serveAsset(c: Context, assetId: string, userId: string) {
+ const [metadata, size] = await Promise.all([
+ readAssetMetadata({
+ userId,
+ assetId,
+ }),
+
+ getAssetSize({
+ userId,
+ assetId,
+ }),
+ ]);
+
+ const range = c.req.header("Range");
+ if (range) {
+ const parts = range.replace(/bytes=/, "").split("-");
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
+
+ const fStream = createAssetReadStream({
+ userId,
+ assetId,
+ start,
+ end,
+ });
+ c.status(206); // Partial Content
+ c.header("Content-Range", `bytes ${start}-${end}/${size}`);
+ c.header("Accept-Ranges", "bytes");
+ c.header("Content-Length", (end - start + 1).toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ } else {
+ const fStream = createAssetReadStream({
+ userId,
+ assetId,
+ });
+ c.status(200);
+ c.header("Content-Length", size.toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ }
+}
diff --git a/packages/db/drizzle/0051_public_lists.sql b/packages/db/drizzle/0051_public_lists.sql
new file mode 100644
index 00000000..6f9714e4
--- /dev/null
+++ b/packages/db/drizzle/0051_public_lists.sql
@@ -0,0 +1 @@
+ALTER TABLE `bookmarkLists` ADD `public` integer DEFAULT false NOT NULL; \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0051_snapshot.json b/packages/db/drizzle/meta/0051_snapshot.json
new file mode 100644
index 00000000..6db03ecf
--- /dev/null
+++ b/packages/db/drizzle/meta/0051_snapshot.json
@@ -0,0 +1,2029 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5a549719-8f7d-49ff-91cc-5ad0f3b5c4ef",
+ "prevId": "a92cdc19-e420-4f05-bfac-9fbbb2f5b8a3",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "userSettings": {
+ "name": "userSettings",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "userSettings_userId_user_id_fk": {
+ "name": "userSettings_userId_user_id_fk",
+ "tableFrom": "userSettings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 18b068c9..765eba59 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -358,6 +358,13 @@
"when": 1748795265779,
"tag": "0050_add_user_settings_archive_display_behaviour",
"breakpoints": true
+ },
+ {
+ "idx": 51,
+ "version": "6",
+ "when": 1748804695561,
+ "tag": "0051_public_lists",
+ "breakpoints": true
}
]
-}
+} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 33ba0350..e79bd2c9 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -338,6 +338,7 @@ export const bookmarkLists = sqliteTable(
),
// Whoever have access to this token can read the content of this list
rssToken: text("rssToken"),
+ public: integer("public", { mode: "boolean" }).notNull().default(false),
},
(bl) => [
index("bookmarkLists_userId_idx").on(bl.userId),
@@ -536,10 +537,14 @@ export const userSettings = sqliteTable("userSettings", {
.references(() => users.id, { onDelete: "cascade" }),
bookmarkClickAction: text("bookmarkClickAction", {
enum: ["open_original_link", "expand_bookmark_preview"],
- }).notNull().default("open_original_link"),
+ })
+ .notNull()
+ .default("open_original_link"),
archiveDisplayBehaviour: text("archiveDisplayBehaviour", {
enum: ["show", "hide"],
- }).notNull().default("show"),
+ })
+ .notNull()
+ .default("show"),
});
// Relations
diff --git a/packages/e2e_tests/docker-compose.yml b/packages/e2e_tests/docker-compose.yml
index 201db154..e1fe46bb 100644
--- a/packages/e2e_tests/docker-compose.yml
+++ b/packages/e2e_tests/docker-compose.yml
@@ -10,6 +10,7 @@ services:
environment:
DATA_DIR: /tmp
NEXTAUTH_SECRET: secret
+ NEXTAUTH_URL: http://localhost:${KARAKEEP_PORT:-3000}
MEILI_MASTER_KEY: dummy
MEILI_ADDR: http://meilisearch:7700
BROWSER_WEB_URL: http://chrome:9222
diff --git a/packages/e2e_tests/tests/api/public.test.ts b/packages/e2e_tests/tests/api/public.test.ts
new file mode 100644
index 00000000..54ef79ea
--- /dev/null
+++ b/packages/e2e_tests/tests/api/public.test.ts
@@ -0,0 +1,322 @@
+import { assert, beforeEach, describe, expect, inject, it } from "vitest";
+import { z } from "zod";
+
+import { createSignedToken } from "../../../shared/signedTokens";
+import { zAssetSignedTokenSchema } from "../../../shared/types/assets";
+import { BookmarkTypes } from "../../../shared/types/bookmarks";
+import { createTestUser, uploadTestAsset } from "../../utils/api";
+import { waitUntil } from "../../utils/general";
+import { getTrpcClient } from "../../utils/trpc";
+
+describe("Public API", () => {
+ const port = inject("karakeepPort");
+
+ if (!port) {
+ throw new Error("Missing required environment variables");
+ }
+
+ let apiKey: string; // For the primary test user
+
+ async function seedDatabase(currentApiKey: string) {
+ const trpcClient = getTrpcClient(currentApiKey);
+
+ // Create two lists
+ const publicList = await trpcClient.lists.create.mutate({
+ name: "Public List",
+ icon: "🚀",
+ type: "manual",
+ });
+
+ await trpcClient.lists.edit.mutate({
+ listId: publicList.id,
+ public: true,
+ });
+
+ // Create two bookmarks
+ const createBookmark1 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #1",
+ url: "http://nginx:80/hello.html",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create a second bookmark with an asset
+ const file = new File(["test content"], "test.pdf", {
+ type: "application/pdf",
+ });
+
+ const uploadResponse = await uploadTestAsset(currentApiKey, port, file);
+ const createBookmark2 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #2",
+ type: BookmarkTypes.ASSET,
+ assetType: "pdf",
+ assetId: uploadResponse.assetId,
+ });
+
+ await trpcClient.lists.addToList.mutate({
+ listId: publicList.id,
+ bookmarkId: createBookmark1.id,
+ });
+ await trpcClient.lists.addToList.mutate({
+ listId: publicList.id,
+ bookmarkId: createBookmark2.id,
+ });
+
+ return { publicList, createBookmark1, createBookmark2 };
+ }
+
+ beforeEach(async () => {
+ apiKey = await createTestUser();
+ });
+
+ it("should get public bookmarks", async () => {
+ const { publicList } = await seedDatabase(apiKey);
+ const trpcClient = getTrpcClient(apiKey);
+
+ const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query(
+ {
+ listId: publicList.id,
+ },
+ );
+
+ expect(res.bookmarks.length).toBe(2);
+ });
+
+ it("should be able to access the assets of the public bookmarks", async () => {
+ const { publicList, createBookmark1, createBookmark2 } =
+ await seedDatabase(apiKey);
+
+ const trpcClient = getTrpcClient(apiKey);
+ // Wait for link bookmark to be crawled and have a banner image (screenshot)
+ await waitUntil(
+ async () => {
+ const res = await trpcClient.bookmarks.getBookmark.query({
+ bookmarkId: createBookmark1.id,
+ });
+ assert(res.content.type === BookmarkTypes.LINK);
+ // Check for screenshotAssetId as bannerImageUrl might be derived from it or original imageUrl
+ return !!res.content.screenshotAssetId || !!res.content.imageUrl;
+ },
+ "Bookmark is crawled and has banner info",
+ 20000, // Increased timeout as crawling can take time
+ );
+
+ const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query(
+ {
+ listId: publicList.id,
+ },
+ );
+
+ const b1Resp = res.bookmarks.find((b) => b.id === createBookmark1.id);
+ expect(b1Resp).toBeDefined();
+ const b2Resp = res.bookmarks.find((b) => b.id === createBookmark2.id);
+ expect(b2Resp).toBeDefined();
+
+ assert(b1Resp!.content.type === BookmarkTypes.LINK);
+ assert(b2Resp!.content.type === BookmarkTypes.ASSET);
+
+ {
+ // Banner image fetch for link bookmark
+ assert(
+ b1Resp!.bannerImageUrl,
+ "Link bookmark should have a bannerImageUrl",
+ );
+ const assetFetch = await fetch(b1Resp!.bannerImageUrl);
+ expect(assetFetch.status).toBe(200);
+ }
+
+ {
+ // Actual asset fetch for asset bookmark
+ assert(
+ b2Resp!.content.assetUrl,
+ "Asset bookmark should have an assetUrl",
+ );
+ const assetFetch = await fetch(b2Resp!.content.assetUrl);
+ expect(assetFetch.status).toBe(200);
+ }
+ });
+
+ it("Accessing non public list should fail", async () => {
+ const trpcClient = getTrpcClient(apiKey);
+ const nonPublicList = await trpcClient.lists.create.mutate({
+ name: "Non Public List",
+ icon: "🚀",
+ type: "manual",
+ });
+
+ await expect(
+ trpcClient.publicBookmarks.getPublicBookmarksInList.query({
+ listId: nonPublicList.id,
+ }),
+ ).rejects.toThrow(/List not found/);
+ });
+
+ describe("Public asset token validation", () => {
+ let userId: string;
+ let assetId: string; // Asset belonging to the primary user (userId)
+
+ beforeEach(async () => {
+ const trpcClient = getTrpcClient(apiKey);
+ const whoami = await trpcClient.users.whoami.query();
+ userId = whoami.id;
+ const assetUpload = await uploadTestAsset(
+ apiKey,
+ port,
+ new File(["test content for token validation"], "token_test.pdf", {
+ type: "application/pdf",
+ }),
+ );
+ assetId = assetUpload.assetId;
+ });
+
+ it("should succeed with a valid token", async () => {
+ const token = createSignedToken(
+ {
+ assetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000, // Expires in 60 seconds
+ );
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(200);
+ expect((await res.blob()).type).toBe("application/pdf");
+ });
+
+ it("should fail without a token", async () => {
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}`,
+ );
+ expect(res.status).toBe(400); // Bad Request due to missing token query param
+ });
+
+ it("should fail with a malformed token string (e.g., not base64)", async () => {
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=thisIsNotValidBase64!@#`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail with a token having a structurally invalid inner payload", async () => {
+ // Payload that doesn't conform to zAssetSignedTokenSchema (e.g. misspelled key)
+ const malformedInnerPayload = {
+ asset_id_mispelled: assetId,
+ userId: userId,
+ };
+ const token = createSignedToken(
+ malformedInnerPayload,
+ Date.now() + 60000,
+ );
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail after token expiry", async () => {
+ const token = createSignedToken(
+ {
+ assetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 1000, // Expires in 1 second
+ );
+
+ // Wait for more than 1 second to ensure expiry
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail when using a valid token for a different asset", async () => {
+ const anotherAssetUpload = await uploadTestAsset(
+ apiKey, // Same user
+ port,
+ new File(["other content"], "other_asset.pdf", {
+ type: "application/pdf",
+ }),
+ );
+ const anotherAssetId = anotherAssetUpload.assetId;
+
+ // Token is valid for 'anotherAssetId'
+ const tokenForAnotherAsset = createSignedToken(
+ {
+ assetId: anotherAssetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ // Attempt to use this token to access the original 'assetId'
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForAnotherAsset}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail if token's userId does not own the requested assetId (expect 404)", async () => {
+ // User1 (primary, `apiKey`, `userId`) owns `assetId` (from beforeEach)
+
+ // Create User2 - ensure unique email for user creation
+ const apiKeyUser2 = await createTestUser();
+ const trpcClientUser2 = getTrpcClient(apiKeyUser2);
+ const whoamiUser2 = await trpcClientUser2.users.whoami.query();
+ const userIdUser2 = whoamiUser2.id;
+
+ // Generate a token where the payload claims assetId is being accessed by userIdUser2,
+ // but assetId actually belongs to the original userId.
+ const tokenForUser2AttemptingAsset1 = createSignedToken(
+ {
+ assetId: assetId, // assetId belongs to user1 (userId)
+ userId: userIdUser2, // token claims user2 is accessing it
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ // User2 attempts to access assetId (owned by User1) using a token that has User2's ID in its payload.
+ // The API route will use userIdUser2 from the token to query the DB for assetId.
+ // Since assetId is not owned by userIdUser2, the DB query will find nothing.
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForUser2AttemptingAsset1}`,
+ );
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Asset not found" }),
+ );
+ });
+
+ it("should fail for a token referencing a non-existent assetId (expect 404)", async () => {
+ const nonExistentAssetId = `nonexistent-asset-${Date.now()}`;
+ const token = createSignedToken(
+ {
+ assetId: nonExistentAssetId,
+ userId, // Valid userId from the primary user
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${nonExistentAssetId}?token=${token}`,
+ );
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Asset not found" }),
+ );
+ });
+ });
+});
diff --git a/packages/e2e_tests/vitest.config.ts b/packages/e2e_tests/vitest.config.ts
index bb1c7ea4..2735f1e2 100644
--- a/packages/e2e_tests/vitest.config.ts
+++ b/packages/e2e_tests/vitest.config.ts
@@ -14,5 +14,8 @@ export default defineConfig({
teardownTimeout: 30000,
include: ["tests/**/*.test.ts"],
testTimeout: 60000,
+ env: {
+ NEXTAUTH_SECRET: "secret",
+ },
},
});
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index adcfe13a..a8eb2ac2 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -426,13 +426,17 @@
"query": {
"type": "string",
"nullable": true
+ },
+ "public": {
+ "type": "boolean"
}
},
"required": [
"id",
"name",
"icon",
- "parentId"
+ "parentId",
+ "public"
]
},
"Tag": {
@@ -1982,6 +1986,9 @@
"query": {
"type": "string",
"minLength": 1
+ },
+ "public": {
+ "type": "boolean"
}
}
}
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index 218b46b0..b899dbeb 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -18,6 +18,7 @@ const optionalStringBool = () =>
const allEnv = z.object({
API_URL: z.string().url().default("http://localhost:3000"),
NEXTAUTH_URL: z.string().url().default("http://localhost:3000"),
+ NEXTAUTH_SECRET: z.string().optional(),
DISABLE_SIGNUPS: stringBool("false"),
DISABLE_PASSWORD_AUTH: stringBool("false"),
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"),
@@ -94,6 +95,12 @@ const serverConfigSchema = allEnv.transform((val) => {
apiUrl: val.API_URL,
publicUrl: val.NEXTAUTH_URL,
publicApiUrl: `${val.NEXTAUTH_URL}/api`,
+ signingSecret: () => {
+ if (!val.NEXTAUTH_SECRET) {
+ throw new Error("NEXTAUTH_SECRET is not set");
+ }
+ return val.NEXTAUTH_SECRET;
+ },
auth: {
disableSignups: val.DISABLE_SIGNUPS,
disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
diff --git a/packages/shared/signedTokens.ts b/packages/shared/signedTokens.ts
new file mode 100644
index 00000000..b5e27f3e
--- /dev/null
+++ b/packages/shared/signedTokens.ts
@@ -0,0 +1,71 @@
+import crypto from "node:crypto";
+import { z } from "zod";
+
+import serverConfig from "./config";
+
+const zTokenPayload = z.object({
+ payload: z.unknown(),
+ expiresAt: z.number(),
+});
+
+const zSignedTokenPayload = z.object({
+ payload: zTokenPayload,
+ signature: z.string(),
+});
+
+export type SignedTokenPayload = z.infer<typeof zSignedTokenPayload>;
+
+export function createSignedToken(
+ payload: unknown,
+ expiryEpoch?: number,
+): string {
+ const expiresAt = expiryEpoch ?? Date.now() + 5 * 60 * 1000; // 5 minutes from now
+
+ const toBeSigned: z.infer<typeof zTokenPayload> = {
+ payload,
+ expiresAt,
+ };
+
+ const payloadString = JSON.stringify(toBeSigned);
+ const signature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(payloadString)
+ .digest("hex");
+
+ const tokenData: z.infer<typeof zSignedTokenPayload> = {
+ payload: toBeSigned,
+ signature,
+ };
+
+ return Buffer.from(JSON.stringify(tokenData)).toString("base64");
+}
+
+export function verifySignedToken<T>(
+ token: string,
+ schema: z.ZodSchema<T>,
+): T | null {
+ try {
+ const tokenData = zSignedTokenPayload.parse(
+ JSON.parse(Buffer.from(token, "base64").toString()),
+ );
+ const { payload, signature } = tokenData;
+
+ // Verify signature
+ const expectedSignature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(JSON.stringify(payload))
+ .digest("hex");
+
+ if (signature !== expectedSignature) {
+ return null;
+ }
+ // Check expiry
+ if (Date.now() > payload.expiresAt) {
+ return null;
+ }
+
+ return schema.parse(payload.payload);
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/shared/types/assets.ts b/packages/shared/types/assets.ts
new file mode 100644
index 00000000..fe0adcfd
--- /dev/null
+++ b/packages/shared/types/assets.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zAssetSignedTokenSchema = z.object({
+ assetId: z.string(),
+ userId: z.string(),
+});
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 3522fad3..ea1ab717 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -250,6 +250,7 @@ export const zPublicBookmarkSchema = z.object({
title: z.string().nullish(),
tags: z.array(z.string()),
description: z.string().nullish(),
+ bannerImageUrl: z.string().nullable(),
content: z.discriminatedUnion("type", [
z.object({
type: z.literal(BookmarkTypes.LINK),
@@ -264,6 +265,7 @@ export const zPublicBookmarkSchema = z.object({
type: z.literal(BookmarkTypes.ASSET),
assetType: z.enum(["image", "pdf"]),
assetId: z.string(),
+ assetUrl: z.string(),
fileName: z.string().nullish(),
sourceUrl: z.string().nullish(),
}),
diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts
index 7ef5687c..51fb458c 100644
--- a/packages/shared/types/lists.ts
+++ b/packages/shared/types/lists.ts
@@ -47,6 +47,7 @@ export const zBookmarkListSchema = z.object({
parentId: z.string().nullable(),
type: z.enum(["manual", "smart"]).default("manual"),
query: z.string().nullish(),
+ public: z.boolean(),
});
export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
@@ -66,6 +67,7 @@ export const zEditBookmarkListSchema = z.object({
icon: z.string().optional(),
parentId: z.string().nullish(),
query: z.string().min(1).optional(),
+ public: z.boolean().optional(),
});
export const zEditBookmarkListSchemaWithValidation = zEditBookmarkListSchema
diff --git a/packages/shared/utils/bookmarkUtils.ts b/packages/shared/utils/bookmarkUtils.ts
index 31d7b698..97ef08fc 100644
--- a/packages/shared/utils/bookmarkUtils.ts
+++ b/packages/shared/utils/bookmarkUtils.ts
@@ -3,18 +3,32 @@ import { getAssetUrl } from "./assetUtils";
const MAX_LOADING_MSEC = 30 * 1000;
-export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+export function getBookmarkLinkAssetIdOrUrl(bookmark: ZBookmarkedLink) {
if (bookmark.imageAssetId) {
- return { url: getAssetUrl(bookmark.imageAssetId), localAsset: true };
+ return { assetId: bookmark.imageAssetId, localAsset: true as const };
}
if (bookmark.screenshotAssetId) {
- return { url: getAssetUrl(bookmark.screenshotAssetId), localAsset: true };
+ return { assetId: bookmark.screenshotAssetId, localAsset: true as const };
}
return bookmark.imageUrl
- ? { url: bookmark.imageUrl, localAsset: false }
+ ? { url: bookmark.imageUrl, localAsset: false as const }
: null;
}
+export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+ const assetOrUrl = getBookmarkLinkAssetIdOrUrl(bookmark);
+ if (!assetOrUrl) {
+ return null;
+ }
+ if (!assetOrUrl.localAsset) {
+ return assetOrUrl;
+ }
+ return {
+ url: getAssetUrl(assetOrUrl.assetId),
+ localAsset: true,
+ };
+}
+
export function isBookmarkStillCrawling(bookmark: ZBookmark) {
return (
bookmark.content.type == BookmarkTypes.LINK &&
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index 524749f9..6e9e5651 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -27,6 +27,9 @@ import {
rssFeedImportsTable,
tagsOnBookmarks,
} from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
+import { createSignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
@@ -36,7 +39,10 @@ import {
ZPublicBookmark,
} from "@karakeep/shared/types/bookmarks";
import { ZCursor } from "@karakeep/shared/types/pagination";
-import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+import {
+ getBookmarkLinkAssetIdOrUrl,
+ getBookmarkTitle,
+} from "@karakeep/shared/utils/bookmarkUtils";
import { AuthedContext } from "..";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
@@ -321,6 +327,14 @@ export class Bookmark implements PrivacyAware {
}
asPublicBookmark(): ZPublicBookmark {
+ const getPublicSignedAssetUrl = (assetId: string) => {
+ const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ assetId,
+ userId: this.ctx.user.id,
+ };
+ const signedToken = createSignedToken(payload);
+ return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
+ };
const getContent = (
content: ZBookmarkContent,
): ZPublicBookmark["content"] => {
@@ -342,6 +356,7 @@ export class Bookmark implements PrivacyAware {
type: BookmarkTypes.ASSET,
assetType: content.assetType,
assetId: content.assetId,
+ assetUrl: getPublicSignedAssetUrl(content.assetId),
fileName: content.fileName,
sourceUrl: content.sourceUrl,
};
@@ -352,6 +367,47 @@ export class Bookmark implements PrivacyAware {
}
};
+ const getBannerImageUrl = (content: ZBookmarkContent): string | null => {
+ switch (content.type) {
+ case BookmarkTypes.LINK: {
+ const assetIdOrUrl = getBookmarkLinkAssetIdOrUrl(content);
+ if (!assetIdOrUrl) {
+ return null;
+ }
+ if (assetIdOrUrl.localAsset) {
+ return getPublicSignedAssetUrl(assetIdOrUrl.assetId);
+ } else {
+ return assetIdOrUrl.url;
+ }
+ }
+ case BookmarkTypes.TEXT: {
+ return null;
+ }
+ case BookmarkTypes.ASSET: {
+ switch (content.assetType) {
+ case "image":
+ return `${getPublicSignedAssetUrl(content.assetId)}`;
+ case "pdf": {
+ const screenshotAssetId = this.bookmark.assets.find(
+ (r) => r.assetType === "assetScreenshot",
+ )?.id;
+ if (!screenshotAssetId) {
+ return null;
+ }
+ return getPublicSignedAssetUrl(screenshotAssetId);
+ }
+ default: {
+ const _exhaustiveCheck: never = content.assetType;
+ return null;
+ }
+ }
+ }
+ default: {
+ throw new Error("Unknown bookmark content type");
+ }
+ }
+ };
+
// WARNING: Everything below is exposed in the public APIs, don't use spreads!
return {
id: this.bookmark.id,
@@ -360,6 +416,7 @@ export class Bookmark implements PrivacyAware {
title: getBookmarkTitle(this.bookmark),
tags: this.bookmark.tags.map((t) => t.name),
content: getContent(this.bookmark.content),
+ bannerImageUrl: getBannerImageUrl(this.bookmark.content),
};
}
}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 4413a8cd..2631ca7e 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
-import { and, count, eq } from "drizzle-orm";
+import { and, count, eq, or } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -8,11 +8,13 @@ import { SqliteError } from "@karakeep/db";
import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema";
import { triggerRuleEngineOnEvent } from "@karakeep/shared/queues";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
+import { ZSortOrder } from "@karakeep/shared/types/bookmarks";
import {
ZBookmarkList,
zEditBookmarkListSchemaWithValidation,
zNewBookmarkListSchema,
} from "@karakeep/shared/types/lists";
+import { ZCursor } from "@karakeep/shared/types/pagination";
import { AuthedContext, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
@@ -61,18 +63,23 @@ export abstract class List implements PrivacyAware {
}
}
- static async getForRss(
+ static async getPublicListContents(
ctx: Context,
listId: string,
- token: string,
+ token: string | null,
pagination: {
limit: number;
+ order: Exclude<ZSortOrder, "relevance">;
+ cursor: ZCursor | null | undefined;
},
) {
const listdb = await ctx.db.query.bookmarkLists.findFirst({
where: and(
eq(bookmarkLists.id, listId),
- eq(bookmarkLists.rssToken, token),
+ or(
+ eq(bookmarkLists.public, true),
+ token !== null ? eq(bookmarkLists.rssToken, token) : undefined,
+ ),
),
});
if (!listdb) {
@@ -85,7 +92,6 @@ export abstract class List implements PrivacyAware {
// The token here acts as an authed context, so we can create
// an impersonating context for the list owner as long as
// we don't leak the context.
-
const authedCtx = await buildImpersonatingAuthedContext(listdb.userId);
const list = List.fromData(authedCtx, listdb);
const bookmarkIds = await list.getBookmarkIds();
@@ -94,7 +100,8 @@ export abstract class List implements PrivacyAware {
ids: bookmarkIds,
includeContent: false,
limit: pagination.limit,
- sortOrder: "desc",
+ sortOrder: pagination.order,
+ cursor: pagination.cursor,
});
return {
@@ -102,8 +109,10 @@ export abstract class List implements PrivacyAware {
icon: list.list.icon,
name: list.list.name,
description: list.list.description,
+ numItems: bookmarkIds.length,
},
bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()),
+ nextCursor: bookmarks.nextCursor,
};
}
@@ -185,6 +194,7 @@ export abstract class List implements PrivacyAware {
icon: input.icon,
parentId: input.parentId,
query: input.query,
+ public: input.public,
})
.where(
and(
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index 394e95e7..e09f959e 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -7,6 +7,7 @@ import { feedsAppRouter } from "./feeds";
import { highlightsAppRouter } from "./highlights";
import { listsAppRouter } from "./lists";
import { promptsAppRouter } from "./prompts";
+import { publicBookmarks } from "./publicBookmarks";
import { rulesAppRouter } from "./rules";
import { tagsAppRouter } from "./tags";
import { usersAppRouter } from "./users";
@@ -25,6 +26,7 @@ export const appRouter = router({
webhooks: webhooksAppRouter,
assets: assetsAppRouter,
rules: rulesAppRouter,
+ publicBookmarks: publicBookmarks,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/publicBookmarks.ts b/packages/trpc/routers/publicBookmarks.ts
new file mode 100644
index 00000000..6b643354
--- /dev/null
+++ b/packages/trpc/routers/publicBookmarks.ts
@@ -0,0 +1,49 @@
+import { z } from "zod";
+
+import {
+ MAX_NUM_BOOKMARKS_PER_PAGE,
+ zPublicBookmarkSchema,
+ zSortOrder,
+} from "@karakeep/shared/types/bookmarks";
+import { zBookmarkListSchema } from "@karakeep/shared/types/lists";
+import { zCursorV2 } from "@karakeep/shared/types/pagination";
+
+import { publicProcedure, router } from "../index";
+import { List } from "../models/lists";
+
+export const publicBookmarks = router({
+ getPublicBookmarksInList: publicProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ cursor: zCursorV2.nullish(),
+ limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).default(20),
+ sortOrder: zSortOrder.exclude(["relevance"]).optional().default("desc"),
+ }),
+ )
+ .output(
+ z.object({
+ list: zBookmarkListSchema
+ .pick({
+ name: true,
+ description: true,
+ icon: true,
+ })
+ .merge(z.object({ numItems: z.number() })),
+ bookmarks: z.array(zPublicBookmarkSchema),
+ nextCursor: zCursorV2.nullable(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ return await List.getPublicListContents(
+ ctx,
+ input.listId,
+ /* token */ null,
+ {
+ limit: input.limit,
+ order: input.sortOrder,
+ cursor: input.cursor,
+ },
+ );
+ }),
+});