From ea1d0023bfee55358ebb1a96f3d06e783a219c0d Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 1 Jun 2025 20:46:41 +0100 Subject: feat: Add support for public lists (#1511) * WIP: public lists * Drop viewing modes * Add the public endpoint for assets * regen the openapi spec * proper handling for different asset types * Add num bookmarks and a no bookmark banner * Correctly set page title * Add a not-found page * merge the RSS and public list endpoints * Add e2e tests for the public endpoints * Redesign the share list modal * Make NEXTAUTH_SECRET not required * propery render text bookmarks * rebase migration * fix public token tests * Add more tests --- apps/web/app/public/layout.tsx | 16 + apps/web/app/public/lists/[listId]/not-found.tsx | 18 + apps/web/app/public/lists/[listId]/page.tsx | 84 + .../bookmarks/BookmarkFormattedCreatedAt.tsx | 8 + .../bookmarks/BookmarkLayoutAdaptingCard.tsx | 13 +- .../bookmarks/BookmarkMarkdownComponent.tsx | 8 +- .../components/dashboard/lists/PublicListLink.tsx | 67 + apps/web/components/dashboard/lists/RssLink.tsx | 107 +- .../components/dashboard/lists/ShareListModal.tsx | 4 +- .../components/public/lists/PublicBookmarkGrid.tsx | 247 +++ .../components/public/lists/PublicListHeader.tsx | 17 + apps/web/components/ui/copy-button.tsx | 41 +- apps/web/lib/i18n/locales/en/translation.json | 11 +- packages/api/index.ts | 4 +- packages/api/routes/assets.ts | 55 +- packages/api/routes/public.ts | 47 + packages/api/routes/rss.ts | 4 +- packages/api/utils/assets.ts | 57 + packages/db/drizzle/0051_public_lists.sql | 1 + packages/db/drizzle/meta/0051_snapshot.json | 2029 ++++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 9 +- packages/db/schema.ts | 9 +- packages/e2e_tests/docker-compose.yml | 1 + packages/e2e_tests/tests/api/public.test.ts | 322 ++++ packages/e2e_tests/vitest.config.ts | 3 + packages/open-api/karakeep-openapi-spec.json | 9 +- packages/shared/config.ts | 7 + packages/shared/signedTokens.ts | 71 + packages/shared/types/assets.ts | 6 + packages/shared/types/bookmarks.ts | 2 + packages/shared/types/lists.ts | 2 + packages/shared/utils/bookmarkUtils.ts | 22 +- packages/trpc/models/bookmarks.ts | 59 +- packages/trpc/models/lists.ts | 22 +- packages/trpc/routers/_app.ts | 2 + packages/trpc/routers/publicBookmarks.ts | 49 + 36 files changed, 3286 insertions(+), 147 deletions(-) create mode 100644 apps/web/app/public/layout.tsx create mode 100644 apps/web/app/public/lists/[listId]/not-found.tsx create mode 100644 apps/web/app/public/lists/[listId]/page.tsx create mode 100644 apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx create mode 100644 apps/web/components/dashboard/lists/PublicListLink.tsx create mode 100644 apps/web/components/public/lists/PublicBookmarkGrid.tsx create mode 100644 apps/web/components/public/lists/PublicListHeader.tsx create mode 100644 packages/api/routes/public.ts create mode 100644 packages/api/utils/assets.ts create mode 100644 packages/db/drizzle/0051_public_lists.sql create mode 100644 packages/db/drizzle/meta/0051_snapshot.json create mode 100644 packages/e2e_tests/tests/api/public.test.ts create mode 100644 packages/shared/signedTokens.ts create mode 100644 packages/shared/types/assets.ts create mode 100644 packages/trpc/routers/publicBookmarks.ts 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 ( +
+
+ +
+
{children}
+
+ ); +} 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 ( +
+
+ +
+

+ List not found +

+

+ The list you're looking for doesn't exist or may have been + removed. +

+
+ ); +} 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 { + // 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 ( +
+
+ + {list.icon} {list.name} + {list.description && ( + + {`(${list.description})`} + + )} + +
+ + + {list.numItems > 0 ? ( + + ) : ( + + )} +
+ ); + } 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 > - + @@ -239,7 +232,7 @@ function CompactView({ bookmark, title, footer, className }: Props) { suppressHydrationWarning className="shrink-0 gap-2 text-gray-500" > - + 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 */} +
+
+ +

+ {t("lists.public_list.description")} +

+
+ { + editList({ + listId: list.id, + public: checked, + }); + }} + /> +
+ + {/* Share URL - only show when public */} + {isPublic && ( + <> +
+ +
+ + publicListUrl} /> +
+
+ + )} + + ); +} 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 ( -
- - RSS - - {!rssUrl ? ( -
- + <> + {/* RSS Feed Toggle */} +
+
+ +

+ {t("lists.rss.description")} +

- ) : ( -
- -
- { - return rssUrl; - }} - className={cn( - buttonVariants({ variant: "outline", size: "sm" }), - "h-8 w-8 p-0", - )} - /> + + checked ? regenRssToken({ listId }) : clearRssToken({ listId }) + } + disabled={ + isTokenLoading || + isClearPending || + isRegenPending || + !!clientConfig.demoMode + } + /> +
+ {/* RSS URL - only show when RSS is enabled */} + {rssEnabled && ( +
+ +
+ + rssUrl} /> -
)} -
+ ); } 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({ {t("lists.share_list")} - + + 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 ( +
+ {tag} +
+ ); +} + +function BookmarkCard({ bookmark }: { bookmark: ZPublicBookmark }) { + const renderContent = () => { + switch (bookmark.content.type) { + case BookmarkTypes.LINK: + return ( +
+ {bookmark.bannerImageUrl && ( +
+ + {bookmark.title + +
+ )} +
+ + {bookmark.title} + +
+
+ ); + + case BookmarkTypes.TEXT: + return ( +
+ {bookmark.title && ( +

+ {bookmark.title} +

+ )} +
+ + {{ + id: bookmark.id, + content: { + text: bookmark.content.text, + }, + }} + + + + + + + + {{ + id: bookmark.id, + content: { + text: bookmark.content.text, + }, + }} + + + +
+
+ ); + + case BookmarkTypes.ASSET: + return ( +
+ {bookmark.bannerImageUrl ? ( +
+ + {bookmark.title + +
+ ) : ( +
+ {bookmark.content.assetType === "image" ? ( + + ) : ( + + )} +
+ )} +
+ + {bookmark.title} + +
+
+ ); + } + }; + + return ( + + + {renderContent()} + + {/* Tags */} + {bookmark.tags.length > 0 && ( +
+ {bookmark.tags.map((tag, index) => ( + + ))} +
+ )} + + {/* Footer */} +
+
+ {bookmark.content.type === BookmarkTypes.LINK && ( + <> + + + + )} + +
+
+
+
+ ); +} + +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 ( + <> + + {bookmarks.map((bookmark) => ( + + ))} + + {hasNextPage && ( +
+ fetchNextPage()} + variant="ghost" + > + Load More + +
+ )} + + ); +} 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 ( +
+ +

+ {list.numItems} bookmarks +

+
+ ); +} 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({ ); } + +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 ( + + ); +} 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, + 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, + 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, + 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, + 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, + 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; + +export function createSignedToken( + payload: unknown, + expiryEpoch?: number, +): string { + const expiresAt = expiryEpoch ?? Date.now() + 5 * 60 * 1000; // 5 minutes from now + + const toBeSigned: z.infer = { + payload, + expiresAt, + }; + + const payloadString = JSON.stringify(toBeSigned); + const signature = crypto + .createHmac("sha256", serverConfig.signingSecret()) + .update(payloadString) + .digest("hex"); + + const tokenData: z.infer = { + payload: toBeSigned, + signature, + }; + + return Buffer.from(JSON.stringify(tokenData)).toString("base64"); +} + +export function verifySignedToken( + token: string, + schema: z.ZodSchema, +): 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; @@ -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 = { + 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; + 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, + }, + ); + }), +}); -- cgit v1.2.3-70-g09d2