diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts
index f3cf1ab4..73237d8d 100644
--- a/apps/web/app/api/assets/[assetId]/route.ts
+++ b/apps/web/app/api/assets/[assetId]/route.ts
@@ -1,5 +1,7 @@
import { createContextFromRequest } from "@/server/api/client";
+import { and, eq } from "drizzle-orm";
+import { assets } from "@hoarder/db/schema";
import { readAsset } from "@hoarder/shared/assetdb";
export const dynamic = "force-dynamic";
@@ -11,6 +13,15 @@ export async function GET(
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+
+ const assetDb = await ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)),
+ });
+
+ if (!assetDb) {
+ return Response.json({ error: "Asset not found" }, { status: 404 });
+ }
+
const { asset, metadata } = await readAsset({
userId: ctx.user.id,
assetId: params.assetId,
diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts
index 9028f556..0e52ff93 100644
--- a/apps/web/app/api/assets/route.ts
+++ b/apps/web/app/api/assets/route.ts
@@ -2,6 +2,7 @@ import { createContextFromRequest } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import type { ZUploadResponse } from "@hoarder/shared/types/uploads";
+import { assets, AssetTypes } from "@hoarder/db/schema";
import {
newAssetId,
saveAsset,
@@ -43,8 +44,22 @@ export async function POST(request: Request) {
return Response.json({ error: "Bad request" }, { status: 400 });
}
- const assetId = newAssetId();
const fileName = data.name;
+ const [assetDb] = await ctx.db
+ .insert(assets)
+ .values({
+ id: newAssetId(),
+ // Initially, uploads are uploaded for unknown purpose
+ // And without an attached bookmark.
+ assetType: AssetTypes.UNKNOWN,
+ bookmarkId: null,
+ userId: ctx.user.id,
+ contentType,
+ size: data.size,
+ fileName,
+ })
+ .returning();
+ const assetId = assetDb.id;
await saveAsset({
userId: ctx.user.id,
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
index a8eaf0f4..436f1026 100644
--- a/apps/web/components/dashboard/preview/AttachmentBox.tsx
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -16,6 +16,7 @@ import {
ChevronsDownUp,
Download,
Image,
+ Paperclip,
Pencil,
Plus,
Trash2,
@@ -35,6 +36,7 @@ import {
import {
humanFriendlyNameForAssertType,
isAllowedToAttachAsset,
+ isAllowedToDetachAsset,
} from "@hoarder/trpc/lib/attachments";
export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
@@ -42,6 +44,8 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
screenshot: ,
fullPageArchive: ,
bannerImage: ,
+ bookmarkAsset: ,
+ unknown: ,
};
const { mutate: attachAsset, isPending: isAttaching } =
@@ -100,11 +104,6 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
bookmark.assets.sort((a, b) => a.assetType.localeCompare(b.assetType));
- if (bookmark.content.type == BookmarkTypes.ASSET) {
- // Currently, we don't allow attaching assets to assets types.
- return null;
- }
-
return (
@@ -156,59 +155,62 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
)}
- (
-
- detachAsset(
- { bookmarkId: bookmark.id, assetId: asset.id },
- { onSettled: () => setDialogOpen(false) },
- )
- }
- >
-
- Delete
-
- )}
- >
-
-
+ {isAllowedToDetachAsset(asset.assetType) && (
+ (
+
+ detachAsset(
+ { bookmarkId: bookmark.id, assetId: asset.id },
+ { onSettled: () => setDialogOpen(false) },
+ )
+ }
+ >
+
+ Delete
+
+ )}
+ >
+
+
+ )}
))}
- {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && (
-
- uploadAsset(file, {
- onSuccess: (resp) => {
- attachAsset({
- bookmarkId: bookmark.id,
- asset: {
- id: resp.assetId,
- assetType: "bannerImage",
- },
- });
- },
- })
- }
- >
-
- Attach a Banner
-
- )}
+ {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") &&
+ bookmark.content.type != BookmarkTypes.ASSET && (
+
+ uploadAsset(file, {
+ onSuccess: (resp) => {
+ attachAsset({
+ bookmarkId: bookmark.id,
+ asset: {
+ id: resp.assetId,
+ assetType: "bannerImage",
+ },
+ });
+ },
+ })
+ }
+ >
+
+ Attach a Banner
+
+ )}
);
diff --git a/apps/workers/crawlerWorker.ts b/apps/workers/crawlerWorker.ts
index f830c500..74413c63 100644
--- a/apps/workers/crawlerWorker.ts
+++ b/apps/workers/crawlerWorker.ts
@@ -36,6 +36,7 @@ import { DequeuedJob, Runner } from "@hoarder/queue";
import {
ASSET_TYPES,
deleteAsset,
+ getAssetSize,
IMAGE_ASSET_TYPES,
newAssetId,
saveAsset,
@@ -192,6 +193,8 @@ export class CrawlerWorker {
}
}
+type DBAssetType = typeof assets.$inferInsert;
+
async function changeBookmarkStatus(
bookmarkId: string,
crawlStatus: "success" | "failure",
@@ -353,16 +356,18 @@ async function storeScreenshot(
return null;
}
const assetId = newAssetId();
+ const contentType = "image/png";
+ const fileName = "screenshot.png";
await saveAsset({
userId,
assetId,
- metadata: { contentType: "image/png", fileName: "screenshot.png" },
+ metadata: { contentType, fileName },
asset: screenshot,
});
logger.info(
`[Crawler][${jobId}] Stored the screenshot as assetId: ${assetId}`,
);
- return assetId;
+ return { assetId, contentType, fileName, size: screenshot.byteLength };
}
async function downloadAndStoreFile(
@@ -396,7 +401,7 @@ async function downloadAndStoreFile(
`[Crawler][${jobId}] Downloaded ${fileType} as assetId: ${assetId}`,
);
- return assetId;
+ return { assetId, userId, contentType, size: buffer.byteLength };
} catch (e) {
logger.error(
`[Crawler][${jobId}] Failed to download and store ${fileType}: ${e}`,
@@ -433,12 +438,14 @@ async function archiveWebpage(
input: html,
})`monolith - -Ije -t 5 -b ${url} -o ${assetPath}`;
+ const contentType = "text/html";
+
await saveAssetFromFile({
userId,
assetId,
assetPath,
metadata: {
- contentType: "text/html",
+ contentType,
},
});
@@ -446,7 +453,11 @@ async function archiveWebpage(
`[Crawler][${jobId}] Done archiving the page as assetId: ${assetId}`,
);
- return assetId;
+ return {
+ assetId,
+ contentType,
+ size: await getAssetSize({ userId, assetId }),
+ };
}
async function getContentType(
@@ -489,17 +500,31 @@ async function handleAsAssetBookmark(
jobId: string,
bookmarkId: string,
) {
- const assetId = await downloadAndStoreFile(url, userId, jobId, assetType);
- if (!assetId) {
+ const downloaded = await downloadAndStoreFile(url, userId, jobId, assetType);
+ if (!downloaded) {
return;
}
+ const fileName = path.basename(new URL(url).pathname);
await db.transaction(async (trx) => {
+ await updateAsset(
+ undefined,
+ {
+ id: downloaded.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.BOOKMARK_ASSET,
+ contentType: downloaded.contentType,
+ size: downloaded.size,
+ fileName,
+ },
+ trx,
+ );
await trx.insert(bookmarkAssets).values({
id: bookmarkId,
assetType,
- assetId,
+ assetId: downloaded.assetId,
content: null,
- fileName: path.basename(new URL(url).pathname),
+ fileName,
sourceUrl: url,
});
// Switch the type of the bookmark from LINK to ASSET
@@ -527,14 +552,24 @@ async function crawlAndParseUrl(
url: browserUrl,
} = await crawlPage(jobId, url);
- const [meta, readableContent, screenshotAssetId] = await Promise.all([
+ const [meta, readableContent, screenshotAssetInfo] = await Promise.all([
extractMetadata(htmlContent, browserUrl, jobId),
extractReadableContent(htmlContent, browserUrl, jobId),
storeScreenshot(screenshot, userId, jobId),
]);
- let imageAssetId: string | null = null;
+ let imageAssetInfo: DBAssetType | null = null;
if (meta.image) {
- imageAssetId = await downloadAndStoreImage(meta.image, userId, jobId);
+ const downloaded = await downloadAndStoreImage(meta.image, userId, jobId);
+ if (downloaded) {
+ imageAssetInfo = {
+ id: downloaded.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_BANNER_IMAGE,
+ contentType: downloaded.contentType,
+ size: downloaded.size,
+ };
+ }
}
// TODO(important): Restrict the size of content to store
@@ -552,22 +587,24 @@ async function crawlAndParseUrl(
})
.where(eq(bookmarkLinks.id, bookmarkId));
- await updateAsset(
- screenshotAssetId,
- oldScreenshotAssetId,
- bookmarkId,
- userId,
- AssetTypes.LINK_SCREENSHOT,
- txn,
- );
- await updateAsset(
- imageAssetId,
- oldImageAssetId,
- bookmarkId,
- userId,
- AssetTypes.LINK_BANNER_IMAGE,
- txn,
- );
+ if (screenshotAssetInfo) {
+ await updateAsset(
+ oldScreenshotAssetId,
+ {
+ id: screenshotAssetInfo.assetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_SCREENSHOT,
+ contentType: screenshotAssetInfo.contentType,
+ size: screenshotAssetInfo.size,
+ fileName: screenshotAssetInfo.fileName,
+ },
+ txn,
+ );
+ }
+ if (imageAssetInfo) {
+ await updateAsset(oldImageAssetId, imageAssetInfo, txn);
+ }
});
// Delete the old assets if any
@@ -582,20 +619,24 @@ async function crawlAndParseUrl(
return async () => {
if (serverConfig.crawler.fullPageArchive || archiveFullPage) {
- const fullPageArchiveAssetId = await archiveWebpage(
- htmlContent,
- browserUrl,
- userId,
- jobId,
- );
+ const {
+ assetId: fullPageArchiveAssetId,
+ size,
+ contentType,
+ } = await archiveWebpage(htmlContent, browserUrl, userId, jobId);
await db.transaction(async (txn) => {
await updateAsset(
- fullPageArchiveAssetId,
oldFullPageArchiveAssetId,
- bookmarkId,
- userId,
- AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ {
+ id: fullPageArchiveAssetId,
+ bookmarkId,
+ userId,
+ assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ contentType,
+ size,
+ fileName: null,
+ },
txn,
);
});
@@ -676,31 +717,13 @@ async function runCrawler(job: DequeuedJob) {
await archivalLogic();
}
-/**
- * Removes the old asset and adds a new one instead
- * @param newAssetId the new assetId to add
- * @param oldAssetId the old assetId to remove (if it exists)
- * @param bookmarkId the id of the bookmark the asset belongs to
- * @param assetType the type of the asset
- * @param txn the transaction where this update should happen in
- */
async function updateAsset(
- newAssetId: string | null,
oldAssetId: string | undefined,
- bookmarkId: string,
- userId: string,
- assetType: AssetTypes,
+ newAsset: DBAssetType,
txn: HoarderDBTransaction,
) {
- if (newAssetId) {
- if (oldAssetId) {
- await txn.delete(assets).where(eq(assets.id, oldAssetId));
- }
- await txn.insert(assets).values({
- id: newAssetId,
- assetType,
- bookmarkId,
- userId,
- });
+ if (oldAssetId) {
+ await txn.delete(assets).where(eq(assets.id, oldAssetId));
}
+ await txn.insert(assets).values(newAsset);
}
diff --git a/packages/db/drizzle/0030_blue_synch.sql b/packages/db/drizzle/0030_blue_synch.sql
new file mode 100644
index 00000000..9b1bac33
--- /dev/null
+++ b/packages/db/drizzle/0030_blue_synch.sql
@@ -0,0 +1,12 @@
+ALTER TABLE `assets` ADD `size` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE `assets` ADD `contentType` text;--> statement-breakpoint
+ALTER TABLE `assets` ADD `fileName` text;--> statement-breakpoint
+INSERT INTO `assets` (`id`, `assetType`, `bookmarkId`, `userId`, `fileName`)
+ SELECT
+ `bookmarkAssets`.`assetId`,
+ 'bookmarkAsset',
+ `bookmarkAssets`.`id`,
+ (SELECT `bookmarks`.`userId` FROM `bookmarks` WHERE `bookmarks`.`id` = `bookmarkAssets`.`id`),
+ `bookmarkAssets`.`fileName`
+ FROM `bookmarkAssets`;
+
diff --git a/packages/db/drizzle/meta/0030_snapshot.json b/packages/db/drizzle/meta/0030_snapshot.json
new file mode 100644
index 00000000..5286196b
--- /dev/null
+++ b/packages/db/drizzle/meta/0030_snapshot.json
@@ -0,0 +1,1222 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "3188359f-c324-4fca-be40-4903de779a2d",
+ "prevId": "e64dea2c-6fd3-4d93-8258-b1f6babe4d07",
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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
+ },
+ "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'"
+ }
+ },
+ "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": {}
+ },
+ "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
+ },
+ "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
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "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": {}
+ },
+ "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
+ }
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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
+ },
+ "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'"
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": {}
+ },
+ "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
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "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": {}
+ },
+ "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": {}
+ },
+ "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": [
+ "bookmarkId"
+ ],
+ "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": {}
+ },
+ "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
+ },
+ "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": {}
+ },
+ "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": {}
+ }
+ },
+ "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 4b29c07f..0a357e2d 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -211,6 +211,13 @@
"when": 1728214930701,
"tag": "0029_short_gunslinger",
"breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "6",
+ "when": 1728220453621,
+ "tag": "0030_blue_synch",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index c11f3b92..6098feb1 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -164,6 +164,8 @@ export const enum AssetTypes {
LINK_BANNER_IMAGE = "linkBannerImage",
LINK_SCREENSHOT = "linkScreenshot",
LINK_FULL_PAGE_ARCHIVE = "linkFullPageArchive",
+ BOOKMARK_ASSET = "bookmarkAsset",
+ UNKNOWN = "unknown",
}
export const assets = sqliteTable(
@@ -176,11 +178,16 @@ export const assets = sqliteTable(
AssetTypes.LINK_BANNER_IMAGE,
AssetTypes.LINK_SCREENSHOT,
AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ AssetTypes.BOOKMARK_ASSET,
+ AssetTypes.UNKNOWN,
],
}).notNull(),
- bookmarkId: text("bookmarkId")
- .notNull()
- .references(() => bookmarks.id, { onDelete: "cascade" }),
+ size: integer("size").notNull().default(0),
+ contentType: text("contentType"),
+ fileName: text("fileName"),
+ bookmarkId: text("bookmarkId").references(() => bookmarks.id, {
+ onDelete: "cascade",
+ }),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -302,7 +309,6 @@ export const bookmarksInLists = sqliteTable(
}),
);
-
export const customPrompts = sqliteTable(
"customPrompts",
{
@@ -312,7 +318,9 @@ export const customPrompts = sqliteTable(
.$defaultFn(() => createId()),
text: text("text").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull(),
- appliesTo: text("attachedBy", { enum: ["all", "text", "images"] }).notNull(),
+ appliesTo: text("attachedBy", {
+ enum: ["all", "text", "images"],
+ }).notNull(),
createdAt: createdAtField(),
userId: text("userId")
.notNull()
diff --git a/packages/shared/assetdb.ts b/packages/shared/assetdb.ts
index dd464139..4edfa1ec 100644
--- a/packages/shared/assetdb.ts
+++ b/packages/shared/assetdb.ts
@@ -120,6 +120,18 @@ export async function readAsset({
return { asset, metadata };
}
+export async function getAssetSize({
+ userId,
+ assetId,
+}: {
+ userId: string;
+ assetId: string;
+}) {
+ const assetDir = getAssetDir(userId, assetId);
+ const stat = await fs.promises.stat(path.join(assetDir, "asset.bin"));
+ return stat.size;
+}
+
export async function deleteAsset({
userId,
assetId,
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 86bbbc1a..f4b4fd4a 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -15,6 +15,8 @@ export const zAssetTypesSchema = z.enum([
"screenshot",
"bannerImage",
"fullPageArchive",
+ "bookmarkAsset",
+ "unknown",
]);
export type ZAssetType = z.infer;
diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts
index 6fe1ef40..175947f8 100644
--- a/packages/trpc/lib/attachments.ts
+++ b/packages/trpc/lib/attachments.ts
@@ -8,6 +8,8 @@ export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType {
[AssetTypes.LINK_SCREENSHOT]: "screenshot",
[AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive",
[AssetTypes.LINK_BANNER_IMAGE]: "bannerImage",
+ [AssetTypes.BOOKMARK_ASSET]: "bookmarkAsset",
+ [AssetTypes.UNKNOWN]: "bannerImage",
};
return map[assetType];
}
@@ -19,6 +21,8 @@ export function mapSchemaAssetTypeToDB(
screenshot: AssetTypes.LINK_SCREENSHOT,
fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE,
bannerImage: AssetTypes.LINK_BANNER_IMAGE,
+ bookmarkAsset: AssetTypes.BOOKMARK_ASSET,
+ unknown: AssetTypes.UNKNOWN,
};
return map[assetType];
}
@@ -28,6 +32,8 @@ export function humanFriendlyNameForAssertType(type: ZAssetType) {
screenshot: "Screenshot",
fullPageArchive: "Full Page Archive",
bannerImage: "Banner Image",
+ bookmarkAsset: "Bookmark Asset",
+ unknown: "Unknown",
};
return map[type];
}
@@ -37,6 +43,19 @@ export function isAllowedToAttachAsset(type: ZAssetType) {
screenshot: true,
fullPageArchive: false,
bannerImage: true,
+ bookmarkAsset: false,
+ unknown: false,
+ };
+ return map[type];
+}
+
+export function isAllowedToDetachAsset(type: ZAssetType) {
+ const map: Record = {
+ screenshot: true,
+ fullPageArchive: true,
+ bannerImage: true,
+ bookmarkAsset: false,
+ unknown: false,
};
return map[type];
}
diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts
index d6a7bc27..d2944c40 100644
--- a/packages/trpc/routers/bookmarks.test.ts
+++ b/packages/trpc/routers/bookmarks.test.ts
@@ -369,6 +369,24 @@ describe("Bookmark Routes", () => {
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 (
@@ -424,7 +442,7 @@ describe("Bookmark Routes", () => {
await api.replaceAsset({
bookmarkId: bookmark.id,
oldAssetId: "asset3",
- newAssetId: "asset4",
+ newAssetId: "asset6",
}),
).rejects.toThrow(/You can't attach this type of asset/);
await expect(
diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts
index b1491a61..f272433a 100644
--- a/packages/trpc/routers/bookmarks.ts
+++ b/packages/trpc/routers/bookmarks.ts
@@ -44,6 +44,7 @@ import type { AuthedContext, Context } from "../index";
import { authedProcedure, router } from "../index";
import {
isAllowedToAttachAsset,
+ isAllowedToDetachAsset,
mapDBAssetTypeToUserType,
mapSchemaAssetTypeToDB,
} from "../lib/attachments";
@@ -80,23 +81,35 @@ export const ensureBookmarkOwnership = experimental_trpcMiddleware<{
return opts.next();
});
-interface Asset {
- id: string;
- assetType: AssetTypes;
-}
-
-function mapAssetsToBookmarkFields(assets: Asset | Asset[] = []) {
- const ASSET_TYE_MAPPING: Record = {
- [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId",
- [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId",
- [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId",
- };
- const assetsArray = Array.isArray(assets) ? assets : [assets];
- return assetsArray.reduce((result: Record, asset: Asset) => {
- result[ASSET_TYE_MAPPING[asset.assetType]] = asset.id;
- return result;
- }, {});
-}
+export const ensureAssetOwnership = async (opts: {
+ ctx: Context;
+ assetId: string;
+}) => {
+ const asset = await opts.ctx.db.query.assets.findFirst({
+ where: eq(bookmarks.id, opts.assetId),
+ columns: {
+ userId: true,
+ },
+ });
+ 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",
+ });
+ }
+};
async function getBookmark(ctx: AuthedContext, bookmarkId: string) {
const bookmark = await ctx.db.query.bookmarks.findFirst({
@@ -189,7 +202,15 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark {
case BookmarkTypes.LINK:
content = {
type: bookmark.type,
- ...mapAssetsToBookmarkFields(assets),
+ screenshotAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_SCREENSHOT,
+ )?.id,
+ fullPageArchiveAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE,
+ )?.id,
+ imageAssetId: assets.find(
+ (a) => a.assetType == AssetTypes.LINK_BANNER_IMAGE,
+ )?.id,
...link,
};
break;
@@ -307,6 +328,19 @@ export const bookmarksAppRouter = router({
sourceUrl: null,
})
.returning();
+ await ensureAssetOwnership({ ctx, assetId: input.assetId });
+ await tx
+ .update(assets)
+ .set({
+ bookmarkId: bookmark.id,
+ assetType: AssetTypes.BOOKMARK_ASSET,
+ })
+ .where(
+ and(
+ eq(assets.id, input.assetId),
+ eq(assets.userId, ctx.user.id),
+ ),
+ );
content = {
type: BookmarkTypes.ASSET,
assetType: asset.assetType,
@@ -647,10 +681,20 @@ export const bookmarksAppRouter = router({
row.assets &&
!acc[bookmarkId].assets.some((a) => a.id == row.assets!.id)
) {
- acc[bookmarkId].content = {
- ...acc[bookmarkId].content,
- ...mapAssetsToBookmarkFields(row.assets),
- };
+ if (acc[bookmarkId].content.type == BookmarkTypes.LINK) {
+ const content = acc[bookmarkId].content;
+ invariant(content.type == BookmarkTypes.LINK);
+ if (row.assets.assetType == AssetTypes.LINK_SCREENSHOT) {
+ content.screenshotAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_FULL_PAGE_ARCHIVE) {
+ content.fullPageArchiveAssetId = row.assets.id;
+ }
+ if (row.assets.assetType == AssetTypes.LINK_BANNER_IMAGE) {
+ content.imageAssetId = row.assets.id;
+ }
+ acc[bookmarkId].content = content;
+ }
acc[bookmarkId].assets.push({
id: row.assets.id,
assetType: mapDBAssetTypeToUserType(row.assets.assetType),
@@ -841,6 +885,7 @@ export const bookmarksAppRouter = router({
.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",
@@ -848,14 +893,14 @@ export const bookmarksAppRouter = router({
});
}
await ctx.db
- .insert(assets)
- .values({
- id: input.asset.id,
+ .update(assets)
+ .set({
assetType: mapSchemaAssetTypeToDB(input.asset.assetType),
bookmarkId: input.bookmarkId,
- userId: ctx.user.id,
})
- .returning();
+ .where(
+ and(eq(assets.id, input.asset.id), eq(assets.userId, ctx.user.id)),
+ );
return input.asset;
}),
replaceAsset: authedProcedure
@@ -869,21 +914,19 @@ export const bookmarksAppRouter = router({
.output(z.void())
.use(ensureBookmarkOwnership)
.mutation(async ({ input, ctx }) => {
- const oldAsset = await ctx.db
+ 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.bookmarkId, input.bookmarkId),
- ),
+ and(eq(assets.id, input.oldAssetId), eq(assets.userId, ctx.user.id)),
)
.limit(1);
- if (!oldAsset.length) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
if (
- !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset[0].assetType))
+ !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset.assetType))
) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -891,21 +934,17 @@ export const bookmarksAppRouter = router({
});
}
- const result = await ctx.db
- .update(assets)
- .set({
- id: input.newAssetId,
- bookmarkId: input.bookmarkId,
- })
- .where(
- and(
- eq(assets.id, input.oldAssetId),
- eq(assets.bookmarkId, input.bookmarkId),
- ),
- );
- if (result.changes == 0) {
- throw new TRPCError({ code: "NOT_FOUND" });
- }
+ 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,
@@ -921,6 +960,21 @@ export const bookmarksAppRouter = router({
.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(
diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts
index 67fbddcc..04e6b0a3 100644
--- a/packages/trpc/testUtils.ts
+++ b/packages/trpc/testUtils.ts
@@ -26,12 +26,13 @@ export async function seedUsers(db: TestDB) {
.returning();
}
-export function getApiCaller(db: TestDB, userId?: string) {
+export function getApiCaller(db: TestDB, userId?: string, email?: string) {
const createCaller = createCallerFactory(appRouter);
return createCaller({
user: userId
? {
id: userId,
+ email,
role: "user",
}
: null,
@@ -55,7 +56,7 @@ export async function buildTestContext(
if (seedDB) {
users = await seedUsers(db);
}
- const callers = users.map((u) => getApiCaller(db, u.id));
+ const callers = users.map((u) => getApiCaller(db, u.id, u.email));
return {
apiCallers: callers,