diff options
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're looking for doesn'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, + }, + ); + }), +}); |
