diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/api/index.ts | 4 | ||||
| -rw-r--r-- | packages/api/middlewares/auth.ts | 17 | ||||
| -rw-r--r-- | packages/api/package.json | 2 | ||||
| -rw-r--r-- | packages/api/routes/rss.ts | 51 | ||||
| -rw-r--r-- | packages/api/utils/rss.ts | 54 | ||||
| -rw-r--r-- | packages/db/drizzle/0049_add_rss_token.sql | 1 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0049_snapshot.json | 2013 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 2 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/rss.test.ts | 155 | ||||
| -rw-r--r-- | packages/shared-react/tsconfig.json | 2 | ||||
| -rw-r--r-- | packages/shared/config.ts | 5 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 29 | ||||
| -rw-r--r-- | packages/shared/types/pagination.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/lib/impersonate.ts | 30 | ||||
| -rw-r--r-- | packages/trpc/models/bookmarks.ts | 365 | ||||
| -rw-r--r-- | packages/trpc/models/lists.ts | 99 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 262 | ||||
| -rw-r--r-- | packages/trpc/routers/lists.ts | 43 |
19 files changed, 2883 insertions, 260 deletions
diff --git a/packages/api/index.ts b/packages/api/index.ts index 00919f3e..a3ba8d42 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -9,6 +9,7 @@ import assets from "./routes/assets"; import bookmarks from "./routes/bookmarks"; import highlights from "./routes/highlights"; import lists from "./routes/lists"; +import rss from "./routes/rss"; import tags from "./routes/tags"; import users from "./routes/users"; @@ -22,7 +23,8 @@ const v1 = new Hono<{ .route("/lists", lists) .route("/tags", tags) .route("/users", users) - .route("/assets", assets); + .route("/assets", assets) + .route("/rss", rss); const app = new Hono<{ Variables: { diff --git a/packages/api/middlewares/auth.ts b/packages/api/middlewares/auth.ts index 7f39a6f9..42bca6c8 100644 --- a/packages/api/middlewares/auth.ts +++ b/packages/api/middlewares/auth.ts @@ -1,11 +1,26 @@ import { createMiddleware } from "hono/factory"; import { HTTPException } from "hono/http-exception"; -import { AuthedContext, createCallerFactory } from "@karakeep/trpc"; +import { AuthedContext, Context, createCallerFactory } from "@karakeep/trpc"; import { appRouter } from "@karakeep/trpc/routers/_app"; const createCaller = createCallerFactory(appRouter); +export const unauthedMiddleware = createMiddleware<{ + Variables: { + ctx: Context; + api: ReturnType<typeof createCaller>; + }; +}>(async (c, next) => { + if (!c.var.ctx) { + throw new HTTPException(401, { + message: "Unauthorized", + }); + } + c.set("api", createCaller(c.get("ctx"))); + await next(); +}); + export const authMiddleware = createMiddleware<{ Variables: { ctx: AuthedContext; diff --git a/packages/api/package.json b/packages/api/package.json index f968ed94..82b2b9d0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -18,6 +18,7 @@ "@karakeep/shared": "workspace:*", "@karakeep/trpc": "workspace:*", "hono": "^4.7.10", + "rss": "^1.2.2", "zod": "^3.24.2" }, "devDependencies": { @@ -26,6 +27,7 @@ "@karakeep/tsconfig": "workspace:^0.1.0", "@types/bcryptjs": "^2.4.6", "@types/deep-equal": "^1.0.4", + "@types/rss": "^0.0.32", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.6.1" }, diff --git a/packages/api/routes/rss.ts b/packages/api/routes/rss.ts new file mode 100644 index 00000000..81c9756c --- /dev/null +++ b/packages/api/routes/rss.ts @@ -0,0 +1,51 @@ +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +import serverConfig from "@karakeep/shared/config"; +import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks"; +import { List } from "@karakeep/trpc/models/lists"; + +import { unauthedMiddleware } from "../middlewares/auth"; +import { toRSS } from "../utils/rss"; + +const app = new Hono().get( + "/lists/:listId", + zValidator( + "query", + z.object({ + token: z.string().min(1), + limit: z.coerce + .number() + .min(1) + .max(MAX_NUM_BOOKMARKS_PER_PAGE) + .optional(), + }), + ), + unauthedMiddleware, + async (c) => { + const listId = c.req.param("listId"); + const searchParams = c.req.valid("query"); + const token = searchParams.token; + + const res = await List.getForRss(c.var.ctx, listId, token, { + limit: searchParams.limit ?? 20, + }); + const list = res.list; + + const rssFeed = toRSS( + { + title: `Bookmarks from ${list.icon} ${list.name}`, + feedUrl: `${serverConfig.publicApiUrl}/v1/rss/lists/${listId}`, + siteUrl: `${serverConfig.publicUrl}/dashboard/lists/${listId}`, + description: list.description ?? undefined, + }, + res.bookmarks, + ); + + c.header("Content-Type", "application/rss+xml"); + return c.body(rssFeed); + }, +); + +export default app; diff --git a/packages/api/utils/rss.ts b/packages/api/utils/rss.ts new file mode 100644 index 00000000..079b3f5a --- /dev/null +++ b/packages/api/utils/rss.ts @@ -0,0 +1,54 @@ +import RSS from "rss"; + +import serverConfig from "@karakeep/shared/config"; +import { + BookmarkTypes, + ZPublicBookmark, +} from "@karakeep/shared/types/bookmarks"; +import { getAssetUrl } from "@karakeep/shared/utils/assetUtils"; + +export function toRSS( + params: { + title: string; + description?: string; + feedUrl: string; + siteUrl: string; + }, + bookmarks: ZPublicBookmark[], +) { + const feed = new RSS({ + title: params.title, + feed_url: params.feedUrl, + site_url: params.siteUrl, + description: params.description, + generator: "Karakeep", + }); + + bookmarks + .filter( + (b) => + b.content.type === BookmarkTypes.LINK || + b.content.type === BookmarkTypes.ASSET, + ) + .forEach((bookmark) => { + feed.item({ + date: bookmark.createdAt, + title: bookmark.title ?? "", + url: + bookmark.content.type === BookmarkTypes.LINK + ? bookmark.content.url + : bookmark.content.type === BookmarkTypes.ASSET + ? `${serverConfig.publicUrl}${getAssetUrl(bookmark.content.assetId)}` + : "", + guid: bookmark.id, + author: + bookmark.content.type === BookmarkTypes.LINK + ? (bookmark.content.author ?? undefined) + : undefined, + categories: bookmark.tags, + description: bookmark.description ?? "", + }); + }); + + return feed.xml({ indent: true }); +} diff --git a/packages/db/drizzle/0049_add_rss_token.sql b/packages/db/drizzle/0049_add_rss_token.sql new file mode 100644 index 00000000..c256f859 --- /dev/null +++ b/packages/db/drizzle/0049_add_rss_token.sql @@ -0,0 +1 @@ +ALTER TABLE `bookmarkLists` ADD `rssToken` text;
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0049_snapshot.json b/packages/db/drizzle/meta/0049_snapshot.json new file mode 100644 index 00000000..4dc9f149 --- /dev/null +++ b/packages/db/drizzle/meta/0049_snapshot.json @@ -0,0 +1,2013 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "98a6c743-e257-4126-a95f-40850e03ee4e", + "prevId": "d3ffc2ee-399e-4652-813d-652b56d649f6", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublished": { + "name": "datePublished", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateModified": { + "name": "dateModified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "crawlStatusCode": { + "name": "crawlStatusCode", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 200 + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rssToken": { + "name": "rssToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkLists_userId_id_idx": { + "name": "bookmarkLists_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + }, + "bookmarkTags_userId_id_idx": { + "name": "bookmarkTags_userId_id_idx", + "columns": [ + "userId", + "id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifiedAt": { + "name": "modifiedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summarizationStatus": { + "name": "summarizationStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "appliesTo": { + "name": "appliesTo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "highlights": { + "name": "highlights", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startOffset": { + "name": "startOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endOffset": { + "name": "endOffset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'yellow'" + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "highlights_bookmarkId_idx": { + "name": "highlights_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "highlights_userId_idx": { + "name": "highlights_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "highlights_bookmarkId_bookmarks_id_fk": { + "name": "highlights_bookmarkId_bookmarks_id_fk", + "tableFrom": "highlights", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_userId_user_id_fk": { + "name": "highlights_userId_user_id_fk", + "tableFrom": "highlights", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeedImports": { + "name": "rssFeedImports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entryId": { + "name": "entryId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rssFeedId": { + "name": "rssFeedId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "rssFeedImports_feedIdIdx_idx": { + "name": "rssFeedImports_feedIdIdx_idx", + "columns": [ + "rssFeedId" + ], + "isUnique": false + }, + "rssFeedImports_entryIdIdx_idx": { + "name": "rssFeedImports_entryIdIdx_idx", + "columns": [ + "entryId" + ], + "isUnique": false + }, + "rssFeedImports_rssFeedId_entryId_unique": { + "name": "rssFeedImports_rssFeedId_entryId_unique", + "columns": [ + "rssFeedId", + "entryId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "rssFeedImports_rssFeedId_rssFeeds_id_fk": { + "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "rssFeeds", + "columnsFrom": [ + "rssFeedId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rssFeedImports_bookmarkId_bookmarks_id_fk": { + "name": "rssFeedImports_bookmarkId_bookmarks_id_fk", + "tableFrom": "rssFeedImports", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rssFeeds": { + "name": "rssFeeds", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchedStatus": { + "name": "lastFetchedStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rssFeeds_userId_idx": { + "name": "rssFeeds_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rssFeeds_userId_user_id_fk": { + "name": "rssFeeds_userId_user_id_fk", + "tableFrom": "rssFeeds", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineActions": { + "name": "ruleEngineActions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ruleId": { + "name": "ruleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngineActions_userId_idx": { + "name": "ruleEngineActions_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "ruleEngineActions_ruleId_idx": { + "name": "ruleEngineActions_ruleId_idx", + "columns": [ + "ruleId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineActions_userId_user_id_fk": { + "name": "ruleEngineActions_userId_user_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_ruleId_ruleEngineRules_id_fk": { + "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "ruleEngineRules", + "columnsFrom": [ + "ruleId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_tagId_fk": { + "name": "ruleEngineActions_userId_tagId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineActions_userId_listId_fk": { + "name": "ruleEngineActions_userId_listId_fk", + "tableFrom": "ruleEngineActions", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ruleEngineRules": { + "name": "ruleEngineRules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ruleEngine_userId_idx": { + "name": "ruleEngine_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "ruleEngineRules_userId_user_id_fk": { + "name": "ruleEngineRules_userId_user_id_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_tagId_fk": { + "name": "ruleEngineRules_userId_tagId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "userId", + "tagId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ruleEngineRules_userId_listId_fk": { + "name": "ruleEngineRules_userId_listId_fk", + "tableFrom": "ruleEngineRules", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "userId", + "listId" + ], + "columnsTo": [ + "userId", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "tagId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userSettings": { + "name": "userSettings", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bookmarkClickAction": { + "name": "bookmarkClickAction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open_original_link'" + } + }, + "indexes": {}, + "foreignKeys": { + "userSettings_userId_user_id_fk": { + "name": "userSettings_userId_user_id_fk", + "tableFrom": "userSettings", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "webhooks_userId_idx": { + "name": "webhooks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "webhooks_userId_user_id_fk": { + "name": "webhooks_userId_user_id_fk", + "tableFrom": "webhooks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index d6219448..85c46b14 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1748086734370, "tag": "0048_add_user_settings", "breakpoints": true + }, + { + "idx": 49, + "version": "6", + "when": 1748699971545, + "tag": "0049_add_rss_token", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 5c109b76..08dbacab 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -336,6 +336,8 @@ export const bookmarkLists = sqliteTable( (): AnySQLiteColumn => bookmarkLists.id, { onDelete: "set null" }, ), + // Whoever have access to this token can read the content of this list + rssToken: text("rssToken"), }, (bl) => [ index("bookmarkLists_userId_idx").on(bl.userId), diff --git a/packages/e2e_tests/tests/api/rss.test.ts b/packages/e2e_tests/tests/api/rss.test.ts new file mode 100644 index 00000000..8a7447e6 --- /dev/null +++ b/packages/e2e_tests/tests/api/rss.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, inject, it } from "vitest"; + +import { BookmarkTypes } from "../../../shared/types/bookmarks"; +import { createTestUser } from "../../utils/api"; +import { getTrpcClient } from "../../utils/trpc"; + +describe("RSS Feed API", () => { + const port = inject("karakeepPort"); + + if (!port) { + throw new Error("Missing required environment variables"); + } + + async function fetchRssFeed(listId: string, token: string) { + return await fetch( + `http://localhost:${port}/api/v1/rss/lists/${listId}?token=${token}`, + ); + } + + async function seedDatabase() { + const trpcClient = getTrpcClient(apiKey); + + // Create two lists + const manualList = await trpcClient.lists.create.mutate({ + name: "Test List #1", + icon: "🚀", + type: "manual", + }); + + const smartList = await trpcClient.lists.create.mutate({ + name: "Test List #2", + icon: "🚀", + type: "smart", + query: "is:fav", + }); + + // Create two bookmarks + const createBookmark1 = await trpcClient.bookmarks.createBookmark.mutate({ + title: "Test Bookmark #1", + url: "https://example.com", + type: BookmarkTypes.LINK, + }); + + const createBookmark2 = await trpcClient.bookmarks.createBookmark.mutate({ + title: "Test Bookmark #2", + url: "https://example.com/2", + type: BookmarkTypes.LINK, + favourited: true, + }); + + await trpcClient.lists.addToList.mutate({ + listId: manualList.id, + bookmarkId: createBookmark1.id, + }); + + return { manualList, smartList, createBookmark1, createBookmark2 }; + } + + let apiKey: string; + + beforeEach(async () => { + apiKey = await createTestUser(); + }); + + it("should generate rss feed for manual lists", async () => { + const { manualList } = await seedDatabase(); + const trpcClient = getTrpcClient(apiKey); + + // Enable rss feed + const token = await trpcClient.lists.regenRssToken.mutate({ + listId: manualList.id, + }); + + const res = await fetchRssFeed(manualList.id, token.token); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/rss+xml"); + + const text = await res.text(); + expect(text).toContain("Test Bookmark #1"); + expect(text).not.toContain("Test Bookmark #2"); + }); + + it("should generate rss feed for smart lists", async () => { + const { smartList } = await seedDatabase(); + const trpcClient = getTrpcClient(apiKey); + + // Enable rss feed + const token = await trpcClient.lists.regenRssToken.mutate({ + listId: smartList.id, + }); + + const res = await fetchRssFeed(smartList.id, token.token); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/rss+xml"); + + const text = await res.text(); + expect(text).not.toContain("Test Bookmark #1"); + expect(text).toContain("Test Bookmark #2"); + }); + + it("should fail when the token is invalid", async () => { + const { smartList } = await seedDatabase(); + const trpcClient = getTrpcClient(apiKey); + + // Enable rss feed + const token = await trpcClient.lists.regenRssToken.mutate({ + listId: smartList.id, + }); + + let res = await fetchRssFeed(smartList.id, token.token); + expect(res.status).toBe(200); + + // Invalidate the token + await trpcClient.lists.regenRssToken.mutate({ + listId: smartList.id, + }); + + res = await fetchRssFeed(smartList.id, token.token); + expect(res.status).toBe(404); + }); + + it("should fail when rss gets disabled", async () => { + const { smartList } = await seedDatabase(); + const trpcClient = getTrpcClient(apiKey); + + // Enable rss feed + const token = await trpcClient.lists.regenRssToken.mutate({ + listId: smartList.id, + }); + + const res = await fetchRssFeed(smartList.id, token.token); + expect(res.status).toBe(200); + + // Disable rss feed + await trpcClient.lists.clearRssToken.mutate({ + listId: smartList.id, + }); + + const res2 = await fetchRssFeed(smartList.id, token.token); + expect(res2.status).toBe(404); + }); + + it("should fail when no token is provided", async () => { + const { smartList } = await seedDatabase(); + const trpcClient = getTrpcClient(apiKey); + + // Enable rss feed + await trpcClient.lists.regenRssToken.mutate({ + listId: smartList.id, + }); + + const res2 = await fetchRssFeed(smartList.id, ""); + expect(res2.status).toBe(400); + }); +}); diff --git a/packages/shared-react/tsconfig.json b/packages/shared-react/tsconfig.json index 513582c4..3d6919d0 100644 --- a/packages/shared-react/tsconfig.json +++ b/packages/shared-react/tsconfig.json @@ -9,6 +9,6 @@ }, "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, - "include": ["**/*.ts", "**/*.tsx", "../shared/utils/bookmarkUtils.ts", "../shared/utils/assetUtils.ts"], + "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } diff --git a/packages/shared/config.ts b/packages/shared/config.ts index c355f9db..218b46b0 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -17,6 +17,7 @@ const optionalStringBool = () => const allEnv = z.object({ API_URL: z.string().url().default("http://localhost:3000"), + NEXTAUTH_URL: z.string().url().default("http://localhost:3000"), DISABLE_SIGNUPS: stringBool("false"), DISABLE_PASSWORD_AUTH: stringBool("false"), OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"), @@ -91,6 +92,8 @@ const allEnv = z.object({ const serverConfigSchema = allEnv.transform((val) => { return { apiUrl: val.API_URL, + publicUrl: val.NEXTAUTH_URL, + publicApiUrl: `${val.NEXTAUTH_URL}/api`, auth: { disableSignups: val.DISABLE_SIGNUPS, disablePasswordAuth: val.DISABLE_PASSWORD_AUTH, @@ -181,6 +184,8 @@ const serverConfigSchema = allEnv.transform((val) => { const serverConfig = serverConfigSchema.parse(process.env); // Always explicitly pick up stuff from server config to avoid accidentally leaking stuff export const clientConfig = { + publicUrl: serverConfig.publicUrl, + publicApiUrl: serverConfig.publicApiUrl, demoMode: serverConfig.demoMode, auth: { disableSignups: serverConfig.auth.disableSignups, diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index 5fe77278..3522fad3 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -242,3 +242,32 @@ export const zSearchBookmarksRequestSchema = z.object({ sortOrder: zSortOrder.optional().default("relevance"), includeContent: z.boolean().optional().default(false), }); + +export const zPublicBookmarkSchema = z.object({ + id: z.string(), + createdAt: z.date(), + modifiedAt: z.date().nullable(), + title: z.string().nullish(), + tags: z.array(z.string()), + description: z.string().nullish(), + content: z.discriminatedUnion("type", [ + z.object({ + type: z.literal(BookmarkTypes.LINK), + url: z.string(), + author: z.string().nullish(), + }), + z.object({ + type: z.literal(BookmarkTypes.TEXT), + text: z.string(), + }), + z.object({ + type: z.literal(BookmarkTypes.ASSET), + assetType: z.enum(["image", "pdf"]), + assetId: z.string(), + fileName: z.string().nullish(), + sourceUrl: z.string().nullish(), + }), + ]), +}); + +export type ZPublicBookmark = z.infer<typeof zPublicBookmarkSchema>; diff --git a/packages/shared/types/pagination.ts b/packages/shared/types/pagination.ts index d2312982..3b9dead4 100644 --- a/packages/shared/types/pagination.ts +++ b/packages/shared/types/pagination.ts @@ -4,3 +4,5 @@ export const zCursorV2 = z.object({ createdAt: z.date(), id: z.string(), }); + +export type ZCursor = z.infer<typeof zCursorV2>; diff --git a/packages/trpc/lib/impersonate.ts b/packages/trpc/lib/impersonate.ts new file mode 100644 index 00000000..f44a2c70 --- /dev/null +++ b/packages/trpc/lib/impersonate.ts @@ -0,0 +1,30 @@ +import { eq } from "drizzle-orm"; + +import { db } from "@karakeep/db"; +import { users } from "@karakeep/db/schema"; + +import { AuthedContext } from ".."; + +export async function buildImpersonatingAuthedContext( + userId: string, +): Promise<AuthedContext> { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) { + throw new Error("User not found"); + } + + return { + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + }, + db, + req: { + ip: null, + }, + }; +} diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts new file mode 100644 index 00000000..524749f9 --- /dev/null +++ b/packages/trpc/models/bookmarks.ts @@ -0,0 +1,365 @@ +import { TRPCError } from "@trpc/server"; +import { + and, + asc, + desc, + eq, + exists, + gt, + gte, + inArray, + lt, + lte, + or, +} from "drizzle-orm"; +import invariant from "tiny-invariant"; +import { z } from "zod"; + +import { + assets, + AssetTypes, + bookmarkAssets, + bookmarkLinks, + bookmarks, + bookmarksInLists, + bookmarkTags, + bookmarkTexts, + rssFeedImportsTable, + tagsOnBookmarks, +} from "@karakeep/db/schema"; +import { + BookmarkTypes, + DEFAULT_NUM_BOOKMARKS_PER_PAGE, + ZBookmark, + ZBookmarkContent, + zGetBookmarksRequestSchema, + ZPublicBookmark, +} from "@karakeep/shared/types/bookmarks"; +import { ZCursor } from "@karakeep/shared/types/pagination"; +import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils"; + +import { AuthedContext } from ".."; +import { mapDBAssetTypeToUserType } from "../lib/attachments"; +import { List } from "./lists"; +import { PrivacyAware } from "./privacy"; + +export class Bookmark implements PrivacyAware { + protected constructor( + protected ctx: AuthedContext, + public bookmark: ZBookmark & { userId: string }, + ) {} + + ensureCanAccess(ctx: AuthedContext): void { + if (this.bookmark.userId != ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + } + + static fromData(ctx: AuthedContext, data: ZBookmark) { + return new Bookmark(ctx, { + ...data, + userId: ctx.user.id, + }); + } + + static async loadMulti( + ctx: AuthedContext, + input: z.infer<typeof zGetBookmarksRequestSchema>, + ): Promise<{ + bookmarks: Bookmark[]; + nextCursor: ZCursor | null; + }> { + if (input.ids && input.ids.length == 0) { + return { bookmarks: [], nextCursor: null }; + } + if (!input.limit) { + input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; + } + if (input.listId) { + const list = await List.fromId(ctx, input.listId); + if (list.type === "smart") { + input.ids = await list.getBookmarkIds(); + delete input.listId; + } + } + + const sq = ctx.db.$with("bookmarksSq").as( + ctx.db + .select() + .from(bookmarks) + .where( + and( + eq(bookmarks.userId, ctx.user.id), + input.archived !== undefined + ? eq(bookmarks.archived, input.archived) + : undefined, + input.favourited !== undefined + ? eq(bookmarks.favourited, input.favourited) + : undefined, + input.ids ? inArray(bookmarks.id, input.ids) : undefined, + input.tagId !== undefined + ? exists( + ctx.db + .select() + .from(tagsOnBookmarks) + .where( + and( + eq(tagsOnBookmarks.bookmarkId, bookmarks.id), + eq(tagsOnBookmarks.tagId, input.tagId), + ), + ), + ) + : undefined, + input.rssFeedId !== undefined + ? exists( + ctx.db + .select() + .from(rssFeedImportsTable) + .where( + and( + eq(rssFeedImportsTable.bookmarkId, bookmarks.id), + eq(rssFeedImportsTable.rssFeedId, input.rssFeedId), + ), + ), + ) + : undefined, + input.listId !== undefined + ? exists( + ctx.db + .select() + .from(bookmarksInLists) + .where( + and( + eq(bookmarksInLists.bookmarkId, bookmarks.id), + eq(bookmarksInLists.listId, input.listId), + ), + ), + ) + : undefined, + input.cursor + ? input.sortOrder === "asc" + ? or( + gt(bookmarks.createdAt, input.cursor.createdAt), + and( + eq(bookmarks.createdAt, input.cursor.createdAt), + gte(bookmarks.id, input.cursor.id), + ), + ) + : or( + lt(bookmarks.createdAt, input.cursor.createdAt), + and( + eq(bookmarks.createdAt, input.cursor.createdAt), + lte(bookmarks.id, input.cursor.id), + ), + ) + : undefined, + ), + ) + .limit(input.limit + 1) + .orderBy( + input.sortOrder === "asc" + ? asc(bookmarks.createdAt) + : desc(bookmarks.createdAt), + desc(bookmarks.id), + ), + ); + // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive + const results = await ctx.db + .with(sq) + .select() + .from(sq) + .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId)) + .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) + .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id)) + .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id)) + .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id)) + .leftJoin(assets, eq(assets.bookmarkId, sq.id)) + .orderBy(desc(sq.createdAt), desc(sq.id)); + + const bookmarksRes = results.reduce<Record<string, ZBookmark>>( + (acc, row) => { + const bookmarkId = row.bookmarksSq.id; + if (!acc[bookmarkId]) { + let content: ZBookmarkContent; + if (row.bookmarkLinks) { + content = { + type: BookmarkTypes.LINK, + url: row.bookmarkLinks.url, + title: row.bookmarkLinks.title, + description: row.bookmarkLinks.description, + imageUrl: row.bookmarkLinks.imageUrl, + favicon: row.bookmarkLinks.favicon, + htmlContent: input.includeContent + ? row.bookmarkLinks.htmlContent + : null, + crawledAt: row.bookmarkLinks.crawledAt, + author: row.bookmarkLinks.author, + publisher: row.bookmarkLinks.publisher, + datePublished: row.bookmarkLinks.datePublished, + dateModified: row.bookmarkLinks.dateModified, + }; + } else if (row.bookmarkTexts) { + content = { + type: BookmarkTypes.TEXT, + text: row.bookmarkTexts.text ?? "", + sourceUrl: row.bookmarkTexts.sourceUrl ?? null, + }; + } else if (row.bookmarkAssets) { + content = { + type: BookmarkTypes.ASSET, + assetId: row.bookmarkAssets.assetId, + assetType: row.bookmarkAssets.assetType, + fileName: row.bookmarkAssets.fileName, + sourceUrl: row.bookmarkAssets.sourceUrl ?? null, + size: null, // This will get filled in the asset loop + content: input.includeContent + ? (row.bookmarkAssets.content ?? null) + : null, + }; + } else { + content = { + type: BookmarkTypes.UNKNOWN, + }; + } + acc[bookmarkId] = { + ...row.bookmarksSq, + content, + tags: [], + assets: [], + }; + } + + if ( + row.bookmarkTags && + // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice + !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id) + ) { + invariant( + row.tagsOnBookmarks, + "if bookmark tag is set, its many-to-many relation must also be set", + ); + acc[bookmarkId].tags.push({ + ...row.bookmarkTags, + attachedBy: row.tagsOnBookmarks.attachedBy, + }); + } + + if ( + row.assets && + !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) + ) { + 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; + } + if (row.assets.assetType == AssetTypes.LINK_VIDEO) { + content.videoAssetId = row.assets.id; + } + if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) { + content.precrawledArchiveAssetId = row.assets.id; + } + acc[bookmarkId].content = content; + } + if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) { + const content = acc[bookmarkId].content; + if (row.assets.id == content.assetId) { + // If this is the bookmark's main aset, caputure its size. + content.size = row.assets.size; + } + } + acc[bookmarkId].assets.push({ + id: row.assets.id, + assetType: mapDBAssetTypeToUserType(row.assets.assetType), + }); + } + + return acc; + }, + {}, + ); + + const bookmarksArr = Object.values(bookmarksRes); + + bookmarksArr.sort((a, b) => { + if (a.createdAt != b.createdAt) { + return input.sortOrder === "asc" + ? a.createdAt.getTime() - b.createdAt.getTime() + : b.createdAt.getTime() - a.createdAt.getTime(); + } else { + return b.id.localeCompare(a.id); + } + }); + + let nextCursor = null; + if (bookmarksArr.length > input.limit) { + const nextItem = bookmarksArr.pop()!; + nextCursor = { + id: nextItem.id, + createdAt: nextItem.createdAt, + }; + } + + return { + bookmarks: bookmarksArr.map((b) => Bookmark.fromData(ctx, b)), + nextCursor, + }; + } + + asZBookmark(): ZBookmark { + return this.bookmark; + } + + asPublicBookmark(): ZPublicBookmark { + const getContent = ( + content: ZBookmarkContent, + ): ZPublicBookmark["content"] => { + switch (content.type) { + case BookmarkTypes.LINK: { + return { + type: BookmarkTypes.LINK, + url: content.url, + }; + } + case BookmarkTypes.TEXT: { + return { + type: BookmarkTypes.TEXT, + text: content.text, + }; + } + case BookmarkTypes.ASSET: { + return { + type: BookmarkTypes.ASSET, + assetType: content.assetType, + assetId: content.assetId, + fileName: content.fileName, + sourceUrl: content.sourceUrl, + }; + } + default: { + throw new Error("Unknown bookmark content type"); + } + } + }; + + // WARNING: Everything below is exposed in the public APIs, don't use spreads! + return { + id: this.bookmark.id, + createdAt: this.bookmark.createdAt, + modifiedAt: this.bookmark.modifiedAt, + title: getBookmarkTitle(this.bookmark), + tags: this.bookmark.tags.map((t) => t.name), + content: getContent(this.bookmark.content), + }; + } +} diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts index 21b23593..4413a8cd 100644 --- a/packages/trpc/models/lists.ts +++ b/packages/trpc/models/lists.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { TRPCError } from "@trpc/server"; import { and, count, eq } from "drizzle-orm"; import invariant from "tiny-invariant"; @@ -13,8 +14,10 @@ import { zNewBookmarkListSchema, } from "@karakeep/shared/types/lists"; -import { AuthedContext } from ".."; +import { AuthedContext, Context } from ".."; +import { buildImpersonatingAuthedContext } from "../lib/impersonate"; import { getBookmarkIdsFromMatcher } from "../lib/search"; +import { Bookmark } from "./bookmarks"; import { PrivacyAware } from "./privacy"; export abstract class List implements PrivacyAware { @@ -58,6 +61,52 @@ export abstract class List implements PrivacyAware { } } + static async getForRss( + ctx: Context, + listId: string, + token: string, + pagination: { + limit: number; + }, + ) { + const listdb = await ctx.db.query.bookmarkLists.findFirst({ + where: and( + eq(bookmarkLists.id, listId), + eq(bookmarkLists.rssToken, token), + ), + }); + if (!listdb) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "List not found", + }); + } + + // The token here acts as an authed context, so we can create + // an impersonating context for the list owner as long as + // we don't leak the context. + + const authedCtx = await buildImpersonatingAuthedContext(listdb.userId); + const list = List.fromData(authedCtx, listdb); + const bookmarkIds = await list.getBookmarkIds(); + + const bookmarks = await Bookmark.loadMulti(authedCtx, { + ids: bookmarkIds, + includeContent: false, + limit: pagination.limit, + sortOrder: "desc", + }); + + return { + list: { + icon: list.list.icon, + name: list.list.name, + description: list.list.description, + }, + bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()), + }; + } + static async create( ctx: AuthedContext, input: z.infer<typeof zNewBookmarkListSchema>, @@ -79,6 +128,9 @@ export abstract class List implements PrivacyAware { static async getAll(ctx: AuthedContext): Promise<(ManualList | SmartList)[]> { const lists = await ctx.db.query.bookmarkLists.findMany({ + columns: { + rssToken: false, + }, where: and(eq(bookmarkLists.userId, ctx.user.id)), }); return lists.map((l) => this.fromData(ctx, l)); @@ -88,7 +140,11 @@ export abstract class List implements PrivacyAware { const lists = await ctx.db.query.bookmarksInLists.findMany({ where: and(eq(bookmarksInLists.bookmarkId, bookmarkId)), with: { - list: true, + list: { + columns: { + rssToken: false, + }, + }, }, }); invariant(lists.map((l) => l.list.userId).every((id) => id == ctx.user.id)); @@ -143,6 +199,45 @@ export abstract class List implements PrivacyAware { this.list = result[0]; } + private async setRssToken(token: string | null) { + const result = await this.ctx.db + .update(bookmarkLists) + .set({ rssToken: token }) + .where( + and( + eq(bookmarkLists.id, this.list.id), + eq(bookmarkLists.userId, this.ctx.user.id), + ), + ) + .returning(); + if (result.length == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + return result[0].rssToken; + } + + async getRssToken(): Promise<string | null> { + const [result] = await this.ctx.db + .select({ rssToken: bookmarkLists.rssToken }) + .from(bookmarkLists) + .where( + and( + eq(bookmarkLists.id, this.list.id), + eq(bookmarkLists.userId, this.ctx.user.id), + ), + ) + .limit(1); + return result.rssToken ?? null; + } + + async regenRssToken() { + return await this.setRssToken(crypto.randomBytes(32).toString("hex")); + } + + async clearRssToken() { + await this.setRssToken(null); + } + abstract get type(): "manual" | "smart"; abstract getBookmarkIds(ctx: AuthedContext): Promise<string[]>; abstract getSize(ctx: AuthedContext): Promise<number>; diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 29a77d8c..04d15d1f 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -1,17 +1,5 @@ import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; -import { - and, - asc, - desc, - eq, - exists, - gt, - gte, - inArray, - lt, - lte, - or, -} from "drizzle-orm"; +import { and, eq, gt, inArray, lt, or } from "drizzle-orm"; import invariant from "tiny-invariant"; import { z } from "zod"; @@ -27,11 +15,9 @@ import { bookmarkAssets, bookmarkLinks, bookmarks, - bookmarksInLists, bookmarkTags, bookmarkTexts, customPrompts, - rssFeedImportsTable, tagsOnBookmarks, } from "@karakeep/db/schema"; import { @@ -69,7 +55,7 @@ import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; import { mapDBAssetTypeToUserType } from "../lib/attachments"; import { getBookmarkIdsFromMatcher } from "../lib/search"; -import { List } from "../models/lists"; +import { Bookmark } from "../models/bookmarks"; import { ensureAssetOwnership } from "./assets"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ @@ -810,245 +796,11 @@ export const bookmarksAppRouter = router({ .input(zGetBookmarksRequestSchema) .output(zGetBookmarksResponseSchema) .query(async ({ input, ctx }) => { - if (input.ids && input.ids.length == 0) { - return { bookmarks: [], nextCursor: null }; - } - if (!input.limit) { - input.limit = DEFAULT_NUM_BOOKMARKS_PER_PAGE; - } - if (input.listId) { - const list = await List.fromId(ctx, input.listId); - if (list.type === "smart") { - input.ids = await list.getBookmarkIds(); - delete input.listId; - } - } - - const sq = ctx.db.$with("bookmarksSq").as( - ctx.db - .select() - .from(bookmarks) - .where( - and( - eq(bookmarks.userId, ctx.user.id), - input.archived !== undefined - ? eq(bookmarks.archived, input.archived) - : undefined, - input.favourited !== undefined - ? eq(bookmarks.favourited, input.favourited) - : undefined, - input.ids ? inArray(bookmarks.id, input.ids) : undefined, - input.tagId !== undefined - ? exists( - ctx.db - .select() - .from(tagsOnBookmarks) - .where( - and( - eq(tagsOnBookmarks.bookmarkId, bookmarks.id), - eq(tagsOnBookmarks.tagId, input.tagId), - ), - ), - ) - : undefined, - input.rssFeedId !== undefined - ? exists( - ctx.db - .select() - .from(rssFeedImportsTable) - .where( - and( - eq(rssFeedImportsTable.bookmarkId, bookmarks.id), - eq(rssFeedImportsTable.rssFeedId, input.rssFeedId), - ), - ), - ) - : undefined, - input.listId !== undefined - ? exists( - ctx.db - .select() - .from(bookmarksInLists) - .where( - and( - eq(bookmarksInLists.bookmarkId, bookmarks.id), - eq(bookmarksInLists.listId, input.listId), - ), - ), - ) - : undefined, - input.cursor - ? input.sortOrder === "asc" - ? or( - gt(bookmarks.createdAt, input.cursor.createdAt), - and( - eq(bookmarks.createdAt, input.cursor.createdAt), - gte(bookmarks.id, input.cursor.id), - ), - ) - : or( - lt(bookmarks.createdAt, input.cursor.createdAt), - and( - eq(bookmarks.createdAt, input.cursor.createdAt), - lte(bookmarks.id, input.cursor.id), - ), - ) - : undefined, - ), - ) - .limit(input.limit + 1) - .orderBy( - input.sortOrder === "asc" - ? asc(bookmarks.createdAt) - : desc(bookmarks.createdAt), - desc(bookmarks.id), - ), - ); - // TODO: Consider not inlining the tags in the response of getBookmarks as this query is getting kinda expensive - const results = await ctx.db - .with(sq) - .select() - .from(sq) - .leftJoin(tagsOnBookmarks, eq(sq.id, tagsOnBookmarks.bookmarkId)) - .leftJoin(bookmarkTags, eq(tagsOnBookmarks.tagId, bookmarkTags.id)) - .leftJoin(bookmarkLinks, eq(bookmarkLinks.id, sq.id)) - .leftJoin(bookmarkTexts, eq(bookmarkTexts.id, sq.id)) - .leftJoin(bookmarkAssets, eq(bookmarkAssets.id, sq.id)) - .leftJoin(assets, eq(assets.bookmarkId, sq.id)) - .orderBy(desc(sq.createdAt), desc(sq.id)); - - const bookmarksRes = results.reduce<Record<string, ZBookmark>>( - (acc, row) => { - const bookmarkId = row.bookmarksSq.id; - if (!acc[bookmarkId]) { - let content: ZBookmarkContent; - if (row.bookmarkLinks) { - content = { - type: BookmarkTypes.LINK, - url: row.bookmarkLinks.url, - title: row.bookmarkLinks.title, - description: row.bookmarkLinks.description, - imageUrl: row.bookmarkLinks.imageUrl, - favicon: row.bookmarkLinks.favicon, - htmlContent: input.includeContent - ? row.bookmarkLinks.htmlContent - : null, - crawledAt: row.bookmarkLinks.crawledAt, - author: row.bookmarkLinks.author, - publisher: row.bookmarkLinks.publisher, - datePublished: row.bookmarkLinks.datePublished, - dateModified: row.bookmarkLinks.dateModified, - }; - } else if (row.bookmarkTexts) { - content = { - type: BookmarkTypes.TEXT, - text: row.bookmarkTexts.text ?? "", - sourceUrl: row.bookmarkTexts.sourceUrl ?? null, - }; - } else if (row.bookmarkAssets) { - content = { - type: BookmarkTypes.ASSET, - assetId: row.bookmarkAssets.assetId, - assetType: row.bookmarkAssets.assetType, - fileName: row.bookmarkAssets.fileName, - sourceUrl: row.bookmarkAssets.sourceUrl ?? null, - size: null, // This will get filled in the asset loop - content: input.includeContent - ? (row.bookmarkAssets.content ?? null) - : null, - }; - } else { - content = { - type: BookmarkTypes.UNKNOWN, - }; - } - acc[bookmarkId] = { - ...row.bookmarksSq, - content, - tags: [], - assets: [], - }; - } - - if ( - row.bookmarkTags && - // Duplicates may occur because of the join, so we need to make sure we're not adding the same tag twice - !acc[bookmarkId].tags.some((t) => t.id == row.bookmarkTags!.id) - ) { - invariant( - row.tagsOnBookmarks, - "if bookmark tag is set, its many-to-many relation must also be set", - ); - acc[bookmarkId].tags.push({ - ...row.bookmarkTags, - attachedBy: row.tagsOnBookmarks.attachedBy, - }); - } - - if ( - row.assets && - !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) - ) { - 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; - } - if (row.assets.assetType == AssetTypes.LINK_VIDEO) { - content.videoAssetId = row.assets.id; - } - if (row.assets.assetType == AssetTypes.LINK_PRECRAWLED_ARCHIVE) { - content.precrawledArchiveAssetId = row.assets.id; - } - acc[bookmarkId].content = content; - } - if (acc[bookmarkId].content.type == BookmarkTypes.ASSET) { - const content = acc[bookmarkId].content; - if (row.assets.id == content.assetId) { - // If this is the bookmark's main aset, caputure its size. - content.size = row.assets.size; - } - } - acc[bookmarkId].assets.push({ - id: row.assets.id, - assetType: mapDBAssetTypeToUserType(row.assets.assetType), - }); - } - - return acc; - }, - {}, - ); - - const bookmarksArr = Object.values(bookmarksRes); - - bookmarksArr.sort((a, b) => { - if (a.createdAt != b.createdAt) { - return input.sortOrder === "asc" - ? a.createdAt.getTime() - b.createdAt.getTime() - : b.createdAt.getTime() - a.createdAt.getTime(); - } else { - return b.id.localeCompare(a.id); - } - }); - - let nextCursor = null; - if (bookmarksArr.length > input.limit) { - const nextItem = bookmarksArr.pop()!; - nextCursor = { - id: nextItem.id, - createdAt: nextItem.createdAt, - }; - } - - return { bookmarks: bookmarksArr, nextCursor }; + const res = await Bookmark.loadMulti(ctx, input); + return { + bookmarks: res.bookmarks.map((b) => b.asZBookmark()), + nextCursor: res.nextCursor, + }; }), updateTags: authedProcedure diff --git a/packages/trpc/routers/lists.ts b/packages/trpc/routers/lists.ts index 65cffd2d..bb949962 100644 --- a/packages/trpc/routers/lists.ts +++ b/packages/trpc/routers/lists.ts @@ -131,4 +131,47 @@ export const listsAppRouter = router({ const sizes = await Promise.all(lists.map((l) => l.getSize())); return { stats: new Map(lists.map((l, i) => [l.list.id, sizes[i]])) }; }), + + // Rss endpoints + regenRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + z.object({ + token: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + const token = await list.regenRssToken(); + return { token: token! }; + }), + clearRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .mutation(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + await list.clearRssToken(); + }), + getRssToken: authedProcedure + .input( + z.object({ + listId: z.string(), + }), + ) + .output( + z.object({ + token: z.string().nullable(), + }), + ) + .query(async ({ input, ctx }) => { + const list = await List.fromId(ctx, input.listId); + return { token: await list.getRssToken() }; + }), }); |
