aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts4
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts2
-rw-r--r--apps/web/app/settings/assets/page.tsx182
-rw-r--r--apps/web/app/settings/layout.tsx6
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx35
-rw-r--r--apps/web/lib/attachments.tsx14
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--apps/web/lib/utils.ts12
-rw-r--r--packages/shared-react/hooks/assets.ts49
-rw-r--r--packages/shared-react/hooks/bookmarks.ts45
-rw-r--r--packages/trpc/lib/attachments.ts2
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/assets.test.ts128
-rw-r--r--packages/trpc/routers/assets.ts213
-rw-r--r--packages/trpc/routers/bookmarks.test.ts119
-rw-r--r--packages/trpc/routers/bookmarks.ts153
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({