diff options
| -rw-r--r-- | apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts | 4 | ||||
| -rw-r--r-- | apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts | 2 | ||||
| -rw-r--r-- | apps/web/app/settings/assets/page.tsx | 182 | ||||
| -rw-r--r-- | apps/web/app/settings/layout.tsx | 6 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/AttachmentBox.tsx | 35 | ||||
| -rw-r--r-- | apps/web/lib/attachments.tsx | 14 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 11 | ||||
| -rw-r--r-- | apps/web/lib/utils.ts | 12 | ||||
| -rw-r--r-- | packages/shared-react/hooks/assets.ts | 49 | ||||
| -rw-r--r-- | packages/shared-react/hooks/bookmarks.ts | 45 | ||||
| -rw-r--r-- | packages/trpc/lib/attachments.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/assets.test.ts | 128 | ||||
| -rw-r--r-- | packages/trpc/routers/assets.ts | 213 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 119 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 153 |
16 files changed, 630 insertions, 347 deletions
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts index 3fc50801..88e203de 100644 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts @@ -12,7 +12,7 @@ export const PUT = ( req, bodySchema: z.object({ assetId: z.string() }), handler: async ({ api, body }) => { - await api.bookmarks.replaceAsset({ + await api.assets.replaceAsset({ bookmarkId: params.params.bookmarkId, oldAssetId: params.params.assetId, newAssetId: body!.assetId, @@ -28,7 +28,7 @@ export const DELETE = ( buildHandler({ req, handler: async ({ api }) => { - await api.bookmarks.detachAsset({ + await api.assets.detachAsset({ bookmarkId: params.params.bookmarkId, assetId: params.params.assetId, }); diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts index e5284a39..156876b6 100644 --- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts +++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts @@ -27,7 +27,7 @@ export const POST = ( req, bodySchema: zAssetSchema, handler: async ({ api, body }) => { - const asset = await api.bookmarks.attachAsset({ + const asset = await api.assets.attachAsset({ bookmarkId: params.params.bookmarkId, asset: body!, }); diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx new file mode 100644 index 00000000..a6c13525 --- /dev/null +++ b/apps/web/app/settings/assets/page.tsx @@ -0,0 +1,182 @@ +"use client"; + +import Link from "next/link"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Button } from "@/components/ui/button"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { formatBytes } from "@/lib/utils"; +import { ExternalLink, Trash2 } from "lucide-react"; + +import { useDetachBookmarkAsset } from "@hoarder/shared-react/hooks/assets"; +import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils"; +import { + humanFriendlyNameForAssertType, + isAllowedToDetachAsset, +} from "@hoarder/trpc/lib/attachments"; + +export default function AssetsSettingsPage() { + const { t } = useTranslation(); + const { mutate: detachAsset, isPending: isDetaching } = + useDetachBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Asset has been deleted!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + const { + data, + isLoading: isAssetsLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = api.assets.list.useInfiniteQuery( + { + limit: 20, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const assets = data?.pages.flatMap((page) => page.assets) ?? []; + + if (isAssetsLoading) { + return <FullPageSpinner />; + } + + return ( + <div className="rounded-md border bg-background p-4"> + <div className="flex flex-col gap-2"> + <div className="mb-2 text-lg font-medium"> + {t("settings.manage_assets.manage_assets")} + </div> + {assets.length === 0 && ( + <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> + {t("settings.manage_assets.no_assets")} + </p> + )} + {assets.length > 0 && ( + <Table> + <TableHeader> + <TableRow> + <TableHead>{t("settings.manage_assets.asset_type")}</TableHead> + <TableHead>{t("common.size")}</TableHead> + <TableHead>{t("settings.manage_assets.asset_link")}</TableHead> + <TableHead> + {t("settings.manage_assets.bookmark_link")} + </TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {assets.map((asset) => ( + <TableRow key={asset.id}> + <TableCell className="flex items-center gap-2"> + {ASSET_TYPE_TO_ICON[asset.assetType]} + <span> + {humanFriendlyNameForAssertType(asset.assetType)} + </span> + </TableCell> + <TableCell>{formatBytes(asset.size)}</TableCell> + <TableCell> + <Link + href={getAssetUrl(asset.id)} + target="_blank" + rel="noopener noreferrer" + className="flex items-center gap-1" + > + <ExternalLink className="size-4" /> + <span>View Asset</span> + </Link> + </TableCell> + <TableCell> + {asset.bookmarkId ? ( + <Link + href={`/dashboard/preview/${asset.bookmarkId}`} + className="flex items-center gap-1" + > + <ExternalLink className="size-4" /> + <span>View Bookmark</span> + </Link> + ) : ( + <span className="text-muted-foreground">No bookmark</span> + )} + </TableCell> + <TableCell> + {isAllowedToDetachAsset(asset.assetType) && + asset.bookmarkId && ( + <ActionConfirmingDialog + title={t("settings.manage_assets.delete_asset")} + description={t( + "settings.manage_assets.delete_asset_confirmation", + )} + actionButton={(setDialogOpen) => ( + <ActionButton + loading={isDetaching} + variant="destructive" + onClick={() => + detachAsset( + { + bookmarkId: asset.bookmarkId!, + assetId: asset.id, + }, + { onSettled: () => setDialogOpen(false) }, + ) + } + > + <Trash2 className="mr-2 size-4" /> + {t("actions.delete")} + </ActionButton> + )} + > + <Button + variant="destructive" + size="sm" + title="Delete" + > + <Trash2 className="size-4" /> + </Button> + </ActionConfirmingDialog> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + {hasNextPage && ( + <div className="flex justify-center"> + <ActionButton + variant="secondary" + onClick={() => fetchNextPage()} + loading={isFetchingNextPage} + ignoreDemoMode={true} + > + Load More + </ActionButton> + </div> + )} + </div> + </div> + ); +} diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 909dfd9c..62ac041c 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -5,6 +5,7 @@ import { TFunction } from "i18next"; import { ArrowLeft, Download, + Image, KeyRound, Link, Rss, @@ -60,6 +61,11 @@ const settingsSidebarItems = ( icon: <Webhook size={18} />, path: "/settings/webhooks", }, + { + name: t("settings.manage_assets.manage_assets"), + icon: <Image size={18} />, + path: "/settings/assets", + }, ]; export default async function SettingsLayout({ diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx index 32939cb0..19622f8d 100644 --- a/apps/web/components/dashboard/preview/AttachmentBox.tsx +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -9,32 +9,18 @@ import { } from "@/components/ui/collapsible"; import FilePickerButton from "@/components/ui/file-picker-button"; import { toast } from "@/components/ui/use-toast"; +import { ASSET_TYPE_TO_ICON } from "@/lib/attachments"; import useUpload from "@/lib/hooks/upload-file"; import { useTranslation } from "@/lib/i18n/client"; -import { - Archive, - Camera, - ChevronsDownUp, - Download, - Image, - Paperclip, - Pencil, - Plus, - Trash2, - Video, -} from "lucide-react"; +import { ChevronsDownUp, Download, Pencil, Plus, Trash2 } from "lucide-react"; import { useAttachBookmarkAsset, useDetachBookmarkAsset, useReplaceBookmarkAsset, -} from "@hoarder/shared-react/hooks/bookmarks"; +} from "@hoarder/shared-react/hooks/assets"; import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils"; -import { - BookmarkTypes, - ZAssetType, - ZBookmark, -} from "@hoarder/shared/types/bookmarks"; +import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; import { humanFriendlyNameForAssertType, isAllowedToAttachAsset, @@ -43,17 +29,6 @@ import { export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { const { t } = useTranslation(); - const typeToIcon: Record<ZAssetType, React.ReactNode> = { - screenshot: <Camera className="size-4" />, - assetScreenshot: <Camera className="size-4" />, - fullPageArchive: <Archive className="size-4" />, - precrawledArchive: <Archive className="size-4" />, - bannerImage: <Image className="size-4" />, - video: <Video className="size-4" />, - bookmarkAsset: <Paperclip className="size-4" />, - unknown: <Paperclip className="size-4" />, - }; - const { mutate: attachAsset, isPending: isAttaching } = useAttachBookmarkAsset({ onSuccess: () => { @@ -124,7 +99,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { href={getAssetUrl(asset.id)} className="flex items-center gap-1" > - {typeToIcon[asset.assetType]} + {ASSET_TYPE_TO_ICON[asset.assetType]} <p>{humanFriendlyNameForAssertType(asset.assetType)}</p> </Link> <div className="flex gap-2"> diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx new file mode 100644 index 00000000..8110d6ce --- /dev/null +++ b/apps/web/lib/attachments.tsx @@ -0,0 +1,14 @@ +import { Archive, Camera, Image, Paperclip, Video } from "lucide-react"; + +import { ZAssetType } from "@hoarder/shared/types/bookmarks"; + +export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = { + screenshot: <Camera className="size-4" />, + assetScreenshot: <Camera className="size-4" />, + fullPageArchive: <Archive className="size-4" />, + precrawledArchive: <Archive className="size-4" />, + bannerImage: <Image className="size-4" />, + video: <Video className="size-4" />, + bookmarkAsset: <Paperclip className="size-4" />, + unknown: <Paperclip className="size-4" />, +}; diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 81ef942f..a12b703f 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -9,6 +9,8 @@ "created_at": "Created At", "key": "Key", "role": "Role", + "type": "Type", + "size": "Size", "roles": { "user": "User", "admin": "Admin" @@ -150,6 +152,15 @@ "last_crawled_at": "Last Crawled At", "crawling_status": "Crawling Status", "crawling_failed": "Crawling Failed" + }, + "manage_assets": { + "manage_assets": "Manage Assets", + "no_assets": "You don't have any assets yet.", + "asset_type": "Asset Type", + "bookmark_link": "Bookmark Link", + "asset_link": "Asset Link", + "delete_asset": "Delete Asset", + "delete_asset_confirmation": "Are you sure you want to delete this asset?" } }, "admin": { diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index 230c9eef..292a6d62 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -8,6 +8,18 @@ export function cn(...inputs: ClassValue[]) { export type OS = "macos" | "ios" | "windows" | "android" | "linux" | null; +export function formatBytes(bytes: number, decimals = 2) { + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +} + export function getOS() { if (typeof window === "undefined") return; const userAgent = window.navigator.userAgent.toLowerCase(); diff --git a/packages/shared-react/hooks/assets.ts b/packages/shared-react/hooks/assets.ts new file mode 100644 index 00000000..b9aeed26 --- /dev/null +++ b/packages/shared-react/hooks/assets.ts @@ -0,0 +1,49 @@ +import { api } from "../trpc"; + +export function useAttachBookmarkAsset( + ...opts: Parameters<typeof api.assets.attachAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.assets.attachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useReplaceBookmarkAsset( + ...opts: Parameters<typeof api.assets.replaceAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.assets.replaceAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useDetachBookmarkAsset( + ...opts: Parameters<typeof api.assets.detachAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.assets.detachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + apiUtils.assets.list.invalidate(); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts index f4dd203c..89715e4f 100644 --- a/packages/shared-react/hooks/bookmarks.ts +++ b/packages/shared-react/hooks/bookmarks.ts @@ -190,48 +190,3 @@ export function useBookmarkPostCreationHook() { return Promise.all(promises); }; } - -export function useAttachBookmarkAsset( - ...opts: Parameters<typeof api.bookmarks.attachAsset.useMutation> -) { - const apiUtils = api.useUtils(); - return api.bookmarks.attachAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} - -export function useReplaceBookmarkAsset( - ...opts: Parameters<typeof api.bookmarks.replaceAsset.useMutation> -) { - const apiUtils = api.useUtils(); - return api.bookmarks.replaceAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} - -export function useDetachBookmarkAsset( - ...opts: Parameters<typeof api.bookmarks.detachAsset.useMutation> -) { - const apiUtils = api.useUtils(); - return api.bookmarks.detachAsset.useMutation({ - ...opts[0], - onSuccess: (res, req, meta) => { - apiUtils.bookmarks.getBookmarks.invalidate(); - apiUtils.bookmarks.searchBookmarks.invalidate(); - apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); - return opts[0]?.onSuccess?.(res, req, meta); - }, - }); -} diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts index 3ad79a5a..29c99172 100644 --- a/packages/trpc/lib/attachments.ts +++ b/packages/trpc/lib/attachments.ts @@ -66,7 +66,7 @@ export function isAllowedToDetachAsset(type: ZAssetType) { screenshot: true, assetScreenshot: true, fullPageArchive: true, - precrawledArchive: false, + precrawledArchive: true, bannerImage: true, video: true, bookmarkAsset: false, diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 0d555a65..7af19884 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -1,6 +1,7 @@ import { router } from "../index"; import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; +import { assetsAppRouter } from "./assets"; import { bookmarksAppRouter } from "./bookmarks"; import { feedsAppRouter } from "./feeds"; import { highlightsAppRouter } from "./highlights"; @@ -21,6 +22,7 @@ export const appRouter = router({ feeds: feedsAppRouter, highlights: highlightsAppRouter, webhooks: webhooksAppRouter, + assets: assetsAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/trpc/routers/assets.test.ts b/packages/trpc/routers/assets.test.ts new file mode 100644 index 00000000..d7db35be --- /dev/null +++ b/packages/trpc/routers/assets.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test } from "vitest"; + +import { assets, AssetTypes } from "@hoarder/db/schema"; +import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks"; + +import type { CustomTestContext } from "../testUtils"; +import { defaultBeforeEach } from "../testUtils"; + +beforeEach<CustomTestContext>(defaultBeforeEach(true)); + +describe("Asset Routes", () => { + test<CustomTestContext>("mutate assets", async ({ apiCallers, db }) => { + const api = apiCallers[0].assets; + const userId = await apiCallers[0].users.whoami().then((u) => u.id); + + const bookmark = await apiCallers[0].bookmarks.createBookmark({ + url: "https://google.com", + type: BookmarkTypes.LINK, + }); + await Promise.all([ + db.insert(assets).values({ + id: "asset1", + assetType: AssetTypes.LINK_SCREENSHOT, + bookmarkId: bookmark.id, + userId, + }), + db.insert(assets).values({ + id: "asset2", + assetType: AssetTypes.LINK_BANNER_IMAGE, + bookmarkId: bookmark.id, + userId, + }), + db.insert(assets).values({ + id: "asset3", + assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + bookmarkId: bookmark.id, + userId, + }), + db.insert(assets).values({ + id: "asset4", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), + db.insert(assets).values({ + id: "asset5", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), + db.insert(assets).values({ + id: "asset6", + assetType: AssetTypes.UNKNOWN, + bookmarkId: null, + userId, + }), + ]); + + const validateAssets = async ( + expected: { id: string; assetType: ZAssetType }[], + ) => { + const b = await apiCallers[0].bookmarks.getBookmark({ + bookmarkId: bookmark.id, + }); + b.assets.sort((a, b) => a.id.localeCompare(b.id)); + expect(b.assets).toEqual(expected); + }; + + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset4", + assetType: "screenshot", + }, + }); + + await validateAssets([ + { id: "asset1", assetType: "screenshot" }, + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + ]); + + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset1", + newAssetId: "asset5", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + await api.detachAsset({ + bookmarkId: bookmark.id, + assetId: "asset4", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + // You're not allowed to attach/replace a fullPageArchive + await expect( + async () => + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset3", + newAssetId: "asset6", + }), + ).rejects.toThrow(/You can't attach this type of asset/); + await expect( + async () => + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset6", + assetType: "fullPageArchive", + }, + }), + ).rejects.toThrow(/You can't attach this type of asset/); + }); +}); diff --git a/packages/trpc/routers/assets.ts b/packages/trpc/routers/assets.ts new file mode 100644 index 00000000..45eac068 --- /dev/null +++ b/packages/trpc/routers/assets.ts @@ -0,0 +1,213 @@ +import { TRPCError } from "@trpc/server"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { assets, bookmarks } from "@hoarder/db/schema"; +import { deleteAsset } from "@hoarder/shared/assetdb"; +import { + zAssetSchema, + zAssetTypesSchema, +} from "@hoarder/shared/types/bookmarks"; + +import { authedProcedure, Context, router } from "../index"; +import { + isAllowedToAttachAsset, + isAllowedToDetachAsset, + mapDBAssetTypeToUserType, + mapSchemaAssetTypeToDB, +} from "../lib/attachments"; +import { ensureBookmarkOwnership } from "./bookmarks"; + +export const ensureAssetOwnership = async (opts: { + ctx: Context; + assetId: string; +}) => { + const asset = await opts.ctx.db.query.assets.findFirst({ + where: eq(bookmarks.id, opts.assetId), + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!asset) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Asset not found", + }); + } + if (asset.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + return asset; +}; + +export const assetsAppRouter = router({ + list: authedProcedure + .input( + z.object({ + limit: z.number().min(1).max(100).default(20), + cursor: z.number().nullish(), // page number + }), + ) + .output( + z.object({ + assets: z.array( + z.object({ + id: z.string(), + assetType: zAssetTypesSchema, + size: z.number(), + contentType: z.string().nullable(), + fileName: z.string().nullable(), + bookmarkId: z.string().nullable(), + }), + ), + nextCursor: z.number().nullish(), + totalCount: z.number(), + }), + ) + .query(async ({ input, ctx }) => { + const page = input.cursor ?? 1; + const [results, totalCount] = await Promise.all([ + ctx.db + .select() + .from(assets) + .where(eq(assets.userId, ctx.user.id)) + .orderBy(desc(assets.size)) + .limit(input.limit) + .offset((page - 1) * input.limit), + ctx.db + .select({ count: sql<number>`count(*)` }) + .from(assets) + .where(eq(assets.userId, ctx.user.id)), + ]); + + return { + assets: results.map((a) => ({ + ...a, + assetType: mapDBAssetTypeToUserType(a.assetType), + })), + nextCursor: page * input.limit < totalCount[0].count ? page + 1 : null, + totalCount: totalCount[0].count, + }; + }), + attachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + asset: zAssetSchema, + }), + ) + .output(zAssetSchema) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + await ensureAssetOwnership({ ctx, assetId: input.asset.id }); + if (!isAllowedToAttachAsset(input.asset.assetType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't attach this type of asset", + }); + } + await ctx.db + .update(assets) + .set({ + assetType: mapSchemaAssetTypeToDB(input.asset.assetType), + bookmarkId: input.bookmarkId, + }) + .where( + and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)), + ); + return input.asset; + }), + replaceAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + oldAssetId: z.string(), + newAssetId: z.string(), + }), + ) + .output(z.void()) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + await Promise.all([ + ensureAssetOwnership({ ctx, assetId: input.oldAssetId }), + ensureAssetOwnership({ ctx, assetId: input.newAssetId }), + ]); + const [oldAsset] = await ctx.db + .select() + .from(assets) + .where( + and(eq(assets.id, input.oldAssetId), eq(assets.userId, ctx.user.id)), + ) + .limit(1); + if ( + !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't attach this type of asset", + }); + } + + await ctx.db.transaction(async (tx) => { + await tx.delete(assets).where(eq(assets.id, input.oldAssetId)); + await tx + .update(assets) + .set({ + bookmarkId: input.bookmarkId, + assetType: oldAsset.assetType, + }) + .where(eq(assets.id, input.newAssetId)); + }); + + await deleteAsset({ + userId: ctx.user.id, + assetId: input.oldAssetId, + }).catch(() => ({})); + }), + detachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + assetId: z.string(), + }), + ) + .output(z.void()) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + await ensureAssetOwnership({ ctx, assetId: input.assetId }); + const [oldAsset] = await ctx.db + .select() + .from(assets) + .where( + and(eq(assets.id, input.assetId), eq(assets.userId, ctx.user.id)), + ); + if ( + !isAllowedToDetachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't deattach this type of asset", + }); + } + const result = await ctx.db + .delete(assets) + .where( + and( + eq(assets.id, input.assetId), + eq(assets.bookmarkId, input.bookmarkId), + ), + ); + if (result.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch( + () => ({}), + ); + }), +}); diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index d2944c40..d89f80fd 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -1,7 +1,7 @@ import { assert, beforeEach, describe, expect, test } from "vitest"; -import { assets, AssetTypes, bookmarks } from "@hoarder/db/schema"; -import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks"; +import { bookmarks } from "@hoarder/db/schema"; +import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; import type { CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; @@ -341,119 +341,4 @@ describe("Bookmark Routes", () => { await validateWithLimit(10); await validateWithLimit(100); }); - - test<CustomTestContext>("mutate assets", async ({ apiCallers, db }) => { - const api = apiCallers[0].bookmarks; - const userId = await apiCallers[0].users.whoami().then((u) => u.id); - - const bookmark = await api.createBookmark({ - url: "https://google.com", - type: BookmarkTypes.LINK, - }); - await Promise.all([ - db.insert(assets).values({ - id: "asset1", - assetType: AssetTypes.LINK_SCREENSHOT, - bookmarkId: bookmark.id, - userId, - }), - db.insert(assets).values({ - id: "asset2", - assetType: AssetTypes.LINK_BANNER_IMAGE, - bookmarkId: bookmark.id, - userId, - }), - db.insert(assets).values({ - id: "asset3", - assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, - bookmarkId: bookmark.id, - userId, - }), - db.insert(assets).values({ - id: "asset4", - assetType: AssetTypes.UNKNOWN, - bookmarkId: null, - userId, - }), - db.insert(assets).values({ - id: "asset5", - assetType: AssetTypes.UNKNOWN, - bookmarkId: null, - userId, - }), - db.insert(assets).values({ - id: "asset6", - assetType: AssetTypes.UNKNOWN, - bookmarkId: null, - userId, - }), - ]); - - const validateAssets = async ( - expected: { id: string; assetType: ZAssetType }[], - ) => { - const b = await api.getBookmark({ bookmarkId: bookmark.id }); - b.assets.sort((a, b) => a.id.localeCompare(b.id)); - expect(b.assets).toEqual(expected); - }; - - await api.attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: "asset4", - assetType: "screenshot", - }, - }); - - await validateAssets([ - { id: "asset1", assetType: "screenshot" }, - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset4", assetType: "screenshot" }, - ]); - - await api.replaceAsset({ - bookmarkId: bookmark.id, - oldAssetId: "asset1", - newAssetId: "asset5", - }); - - await validateAssets([ - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset4", assetType: "screenshot" }, - { id: "asset5", assetType: "screenshot" }, - ]); - - await api.detachAsset({ - bookmarkId: bookmark.id, - assetId: "asset4", - }); - - await validateAssets([ - { id: "asset2", assetType: "bannerImage" }, - { id: "asset3", assetType: "fullPageArchive" }, - { id: "asset5", assetType: "screenshot" }, - ]); - - // You're not allowed to attach/replace a fullPageArchive - await expect( - async () => - await api.replaceAsset({ - bookmarkId: bookmark.id, - oldAssetId: "asset3", - newAssetId: "asset6", - }), - ).rejects.toThrow(/You can't attach this type of asset/); - await expect( - async () => - await api.attachAsset({ - bookmarkId: bookmark.id, - asset: { - id: "asset6", - assetType: "fullPageArchive", - }, - }), - ).rejects.toThrow(/You can't attach this type of asset/); - }); }); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 6ab863fb..3b2d23ce 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -55,7 +55,6 @@ import { parseSearchQuery } from "@hoarder/shared/searchQueryParser"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, - zAssetSchema, zBareBookmarkSchema, zBookmarkSchema, zGetBookmarksRequestSchema, @@ -69,13 +68,9 @@ import { import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; -import { - isAllowedToAttachAsset, - isAllowedToDetachAsset, - mapDBAssetTypeToUserType, - mapSchemaAssetTypeToDB, -} from "../lib/attachments"; +import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; +import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ ctx: Context; @@ -109,34 +104,6 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ return opts.next(); }); -export const ensureAssetOwnership = async (opts: { - ctx: Context; - assetId: string; -}) => { - const asset = await opts.ctx.db.query.assets.findFirst({ - where: eq(bookmarks.id, opts.assetId), - }); - if (!opts.ctx.user) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "User is not authorized", - }); - } - if (!asset) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Asset not found", - }); - } - if (asset.userId != opts.ctx.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "User is not allowed to access resource", - }); - } - return asset; -}; - async function getBookmark(ctx: AuthedContext, bookmarkId: string) { const bookmark = await ctx.db.query.bookmarks.findFirst({ where: and(eq(bookmarks.userId, ctx.user.id), eq(bookmarks.id, bookmarkId)), @@ -1060,122 +1027,6 @@ export const bookmarksAppRouter = router({ }; }); }), - - attachAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - asset: zAssetSchema, - }), - ) - .output(zAssetSchema) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - await ensureAssetOwnership({ ctx, assetId: input.asset.id }); - if (!isAllowedToAttachAsset(input.asset.assetType)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You can't attach this type of asset", - }); - } - await ctx.db - .update(assets) - .set({ - assetType: mapSchemaAssetTypeToDB(input.asset.assetType), - bookmarkId: input.bookmarkId, - }) - .where( - and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)), - ); - return input.asset; - }), - replaceAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - oldAssetId: z.string(), - newAssetId: z.string(), - }), - ) - .output(z.void()) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - await Promise.all([ - ensureAssetOwnership({ ctx, assetId: input.oldAssetId }), - ensureAssetOwnership({ ctx, assetId: input.newAssetId }), - ]); - const [oldAsset] = await ctx.db - .select() - .from(assets) - .where( - and(eq(assets.id, input.oldAssetId), eq(assets.userId, ctx.user.id)), - ) - .limit(1); - if ( - !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You can't attach this type of asset", - }); - } - - await ctx.db.transaction(async (tx) => { - await tx.delete(assets).where(eq(assets.id, input.oldAssetId)); - await tx - .update(assets) - .set({ - bookmarkId: input.bookmarkId, - assetType: oldAsset.assetType, - }) - .where(eq(assets.id, input.newAssetId)); - }); - - await deleteAsset({ - userId: ctx.user.id, - assetId: input.oldAssetId, - }).catch(() => ({})); - }), - detachAsset: authedProcedure - .input( - z.object({ - bookmarkId: z.string(), - assetId: z.string(), - }), - ) - .output(z.void()) - .use(ensureBookmarkOwnership) - .mutation(async ({ input, ctx }) => { - await ensureAssetOwnership({ ctx, assetId: input.assetId }); - const [oldAsset] = await ctx.db - .select() - .from(assets) - .where( - and(eq(assets.id, input.assetId), eq(assets.userId, ctx.user.id)), - ); - if ( - !isAllowedToDetachAsset(mapDBAssetTypeToUserType(oldAsset.assetType)) - ) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "You can't deattach this type of asset", - }); - } - const result = await ctx.db - .delete(assets) - .where( - and( - eq(assets.id, input.assetId), - eq(assets.bookmarkId, input.bookmarkId), - ), - ); - if (result.changes == 0) { - throw new TRPCError({ code: "NOT_FOUND" }); - } - await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch( - () => ({}), - ); - }), getBrokenLinks: authedProcedure .output( z.object({ |
