aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/api/index.ts4
-rw-r--r--packages/api/routes/assets.ts55
-rw-r--r--packages/api/routes/public.ts47
-rw-r--r--packages/api/routes/rss.ts4
-rw-r--r--packages/api/utils/assets.ts57
-rw-r--r--packages/db/drizzle/0051_public_lists.sql1
-rw-r--r--packages/db/drizzle/meta/0051_snapshot.json2029
-rw-r--r--packages/db/drizzle/meta/_journal.json9
-rw-r--r--packages/db/schema.ts9
-rw-r--r--packages/e2e_tests/docker-compose.yml1
-rw-r--r--packages/e2e_tests/tests/api/public.test.ts322
-rw-r--r--packages/e2e_tests/vitest.config.ts3
-rw-r--r--packages/open-api/karakeep-openapi-spec.json9
-rw-r--r--packages/shared/config.ts7
-rw-r--r--packages/shared/signedTokens.ts71
-rw-r--r--packages/shared/types/assets.ts6
-rw-r--r--packages/shared/types/bookmarks.ts2
-rw-r--r--packages/shared/types/lists.ts2
-rw-r--r--packages/shared/utils/bookmarkUtils.ts22
-rw-r--r--packages/trpc/models/bookmarks.ts59
-rw-r--r--packages/trpc/models/lists.ts22
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/publicBookmarks.ts49
23 files changed, 2723 insertions, 69 deletions
diff --git a/packages/api/index.ts b/packages/api/index.ts
index a3ba8d42..5147ea37 100644
--- a/packages/api/index.ts
+++ b/packages/api/index.ts
@@ -9,6 +9,7 @@ import assets from "./routes/assets";
import bookmarks from "./routes/bookmarks";
import highlights from "./routes/highlights";
import lists from "./routes/lists";
+import publicRoute from "./routes/public";
import rss from "./routes/rss";
import tags from "./routes/tags";
import users from "./routes/users";
@@ -43,6 +44,7 @@ const app = new Hono<{
})
.use(trpcAdapter)
.route("/v1", v1)
- .route("/assets", assets);
+ .route("/assets", assets)
+ .route("/public", publicRoute);
export default app;
diff --git a/packages/api/routes/assets.ts b/packages/api/routes/assets.ts
index de4e384d..9d9a60b3 100644
--- a/packages/api/routes/assets.ts
+++ b/packages/api/routes/assets.ts
@@ -1,18 +1,13 @@
import { zValidator } from "@hono/zod-validator";
import { and, eq } from "drizzle-orm";
import { Hono } from "hono";
-import { stream } from "hono/streaming";
import { z } from "zod";
import { assets } from "@karakeep/db/schema";
-import {
- createAssetReadStream,
- getAssetSize,
- readAssetMetadata,
-} from "@karakeep/shared/assetdb";
import { authMiddleware } from "../middlewares/auth";
-import { toWebReadableStream, uploadAsset } from "../utils/upload";
+import { serveAsset } from "../utils/assets";
+import { uploadAsset } from "../utils/upload";
const app = new Hono()
.use(authMiddleware)
@@ -47,51 +42,7 @@ const app = new Hono()
if (!assetDb) {
return c.json({ error: "Asset not found" }, { status: 404 });
}
-
- const [metadata, size] = await Promise.all([
- readAssetMetadata({
- userId: c.var.ctx.user.id,
- assetId,
- }),
-
- getAssetSize({
- userId: c.var.ctx.user.id,
- assetId,
- }),
- ]);
-
- const range = c.req.header("Range");
- if (range) {
- const parts = range.replace(/bytes=/, "").split("-");
- const start = parseInt(parts[0], 10);
- const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
-
- const fStream = createAssetReadStream({
- userId: c.var.ctx.user.id,
- assetId,
- start,
- end,
- });
- c.status(206); // Partial Content
- c.header("Content-Range", `bytes ${start}-${end}/${size}`);
- c.header("Accept-Ranges", "bytes");
- c.header("Content-Length", (end - start + 1).toString());
- c.header("Content-type", metadata.contentType);
- return stream(c, async (stream) => {
- await stream.pipe(toWebReadableStream(fStream));
- });
- } else {
- const fStream = createAssetReadStream({
- userId: c.var.ctx.user.id,
- assetId,
- });
- c.status(200);
- c.header("Content-Length", size.toString());
- c.header("Content-type", metadata.contentType);
- return stream(c, async (stream) => {
- await stream.pipe(toWebReadableStream(fStream));
- });
- }
+ return await serveAsset(c, assetId, c.var.ctx.user.id);
});
export default app;
diff --git a/packages/api/routes/public.ts b/packages/api/routes/public.ts
new file mode 100644
index 00000000..d17049c4
--- /dev/null
+++ b/packages/api/routes/public.ts
@@ -0,0 +1,47 @@
+import { zValidator } from "@hono/zod-validator";
+import { and, eq } from "drizzle-orm";
+import { Hono } from "hono";
+import { z } from "zod";
+
+import { assets } from "@karakeep/db/schema";
+import { verifySignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
+
+import { unauthedMiddleware } from "../middlewares/auth";
+import { serveAsset } from "../utils/assets";
+
+const app = new Hono().get(
+ "/assets/:assetId",
+ unauthedMiddleware,
+ zValidator(
+ "query",
+ z.object({
+ token: z.string(),
+ }),
+ ),
+ async (c) => {
+ const assetId = c.req.param("assetId");
+ const tokenPayload = verifySignedToken(
+ c.req.valid("query").token,
+ zAssetSignedTokenSchema,
+ );
+ if (!tokenPayload) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ if (tokenPayload.assetId !== assetId) {
+ return c.json({ error: "Invalid or expired token" }, { status: 403 });
+ }
+ const userId = tokenPayload.userId;
+
+ const assetDb = await c.var.ctx.db.query.assets.findFirst({
+ where: and(eq(assets.id, assetId), eq(assets.userId, userId)),
+ });
+
+ if (!assetDb) {
+ return c.json({ error: "Asset not found" }, { status: 404 });
+ }
+ return await serveAsset(c, assetId, userId);
+ },
+);
+
+export default app;
diff --git a/packages/api/routes/rss.ts b/packages/api/routes/rss.ts
index 81c9756c..88b943ad 100644
--- a/packages/api/routes/rss.ts
+++ b/packages/api/routes/rss.ts
@@ -28,8 +28,10 @@ const app = new Hono().get(
const searchParams = c.req.valid("query");
const token = searchParams.token;
- const res = await List.getForRss(c.var.ctx, listId, token, {
+ const res = await List.getPublicListContents(c.var.ctx, listId, token, {
limit: searchParams.limit ?? 20,
+ order: "desc",
+ cursor: null,
});
const list = res.list;
diff --git a/packages/api/utils/assets.ts b/packages/api/utils/assets.ts
new file mode 100644
index 00000000..d8a726a6
--- /dev/null
+++ b/packages/api/utils/assets.ts
@@ -0,0 +1,57 @@
+import { Context } from "hono";
+import { stream } from "hono/streaming";
+
+import {
+ createAssetReadStream,
+ getAssetSize,
+ readAssetMetadata,
+} from "@karakeep/shared/assetdb";
+
+import { toWebReadableStream } from "./upload";
+
+export async function serveAsset(c: Context, assetId: string, userId: string) {
+ const [metadata, size] = await Promise.all([
+ readAssetMetadata({
+ userId,
+ assetId,
+ }),
+
+ getAssetSize({
+ userId,
+ assetId,
+ }),
+ ]);
+
+ const range = c.req.header("Range");
+ if (range) {
+ const parts = range.replace(/bytes=/, "").split("-");
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : size - 1;
+
+ const fStream = createAssetReadStream({
+ userId,
+ assetId,
+ start,
+ end,
+ });
+ c.status(206); // Partial Content
+ c.header("Content-Range", `bytes ${start}-${end}/${size}`);
+ c.header("Accept-Ranges", "bytes");
+ c.header("Content-Length", (end - start + 1).toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ } else {
+ const fStream = createAssetReadStream({
+ userId,
+ assetId,
+ });
+ c.status(200);
+ c.header("Content-Length", size.toString());
+ c.header("Content-type", metadata.contentType);
+ return stream(c, async (stream) => {
+ await stream.pipe(toWebReadableStream(fStream));
+ });
+ }
+}
diff --git a/packages/db/drizzle/0051_public_lists.sql b/packages/db/drizzle/0051_public_lists.sql
new file mode 100644
index 00000000..6f9714e4
--- /dev/null
+++ b/packages/db/drizzle/0051_public_lists.sql
@@ -0,0 +1 @@
+ALTER TABLE `bookmarkLists` ADD `public` integer DEFAULT false NOT NULL; \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0051_snapshot.json b/packages/db/drizzle/meta/0051_snapshot.json
new file mode 100644
index 00000000..6db03ecf
--- /dev/null
+++ b/packages/db/drizzle/meta/0051_snapshot.json
@@ -0,0 +1,2029 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5a549719-8f7d-49ff-91cc-5ad0f3b5c4ef",
+ "prevId": "a92cdc19-e420-4f05-bfac-9fbbb2f5b8a3",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "userSettings": {
+ "name": "userSettings",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "userSettings_userId_user_id_fk": {
+ "name": "userSettings_userId_user_id_fk",
+ "tableFrom": "userSettings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 18b068c9..765eba59 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -358,6 +358,13 @@
"when": 1748795265779,
"tag": "0050_add_user_settings_archive_display_behaviour",
"breakpoints": true
+ },
+ {
+ "idx": 51,
+ "version": "6",
+ "when": 1748804695561,
+ "tag": "0051_public_lists",
+ "breakpoints": true
}
]
-}
+} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 33ba0350..e79bd2c9 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -338,6 +338,7 @@ export const bookmarkLists = sqliteTable(
),
// Whoever have access to this token can read the content of this list
rssToken: text("rssToken"),
+ public: integer("public", { mode: "boolean" }).notNull().default(false),
},
(bl) => [
index("bookmarkLists_userId_idx").on(bl.userId),
@@ -536,10 +537,14 @@ export const userSettings = sqliteTable("userSettings", {
.references(() => users.id, { onDelete: "cascade" }),
bookmarkClickAction: text("bookmarkClickAction", {
enum: ["open_original_link", "expand_bookmark_preview"],
- }).notNull().default("open_original_link"),
+ })
+ .notNull()
+ .default("open_original_link"),
archiveDisplayBehaviour: text("archiveDisplayBehaviour", {
enum: ["show", "hide"],
- }).notNull().default("show"),
+ })
+ .notNull()
+ .default("show"),
});
// Relations
diff --git a/packages/e2e_tests/docker-compose.yml b/packages/e2e_tests/docker-compose.yml
index 201db154..e1fe46bb 100644
--- a/packages/e2e_tests/docker-compose.yml
+++ b/packages/e2e_tests/docker-compose.yml
@@ -10,6 +10,7 @@ services:
environment:
DATA_DIR: /tmp
NEXTAUTH_SECRET: secret
+ NEXTAUTH_URL: http://localhost:${KARAKEEP_PORT:-3000}
MEILI_MASTER_KEY: dummy
MEILI_ADDR: http://meilisearch:7700
BROWSER_WEB_URL: http://chrome:9222
diff --git a/packages/e2e_tests/tests/api/public.test.ts b/packages/e2e_tests/tests/api/public.test.ts
new file mode 100644
index 00000000..54ef79ea
--- /dev/null
+++ b/packages/e2e_tests/tests/api/public.test.ts
@@ -0,0 +1,322 @@
+import { assert, beforeEach, describe, expect, inject, it } from "vitest";
+import { z } from "zod";
+
+import { createSignedToken } from "../../../shared/signedTokens";
+import { zAssetSignedTokenSchema } from "../../../shared/types/assets";
+import { BookmarkTypes } from "../../../shared/types/bookmarks";
+import { createTestUser, uploadTestAsset } from "../../utils/api";
+import { waitUntil } from "../../utils/general";
+import { getTrpcClient } from "../../utils/trpc";
+
+describe("Public API", () => {
+ const port = inject("karakeepPort");
+
+ if (!port) {
+ throw new Error("Missing required environment variables");
+ }
+
+ let apiKey: string; // For the primary test user
+
+ async function seedDatabase(currentApiKey: string) {
+ const trpcClient = getTrpcClient(currentApiKey);
+
+ // Create two lists
+ const publicList = await trpcClient.lists.create.mutate({
+ name: "Public List",
+ icon: "🚀",
+ type: "manual",
+ });
+
+ await trpcClient.lists.edit.mutate({
+ listId: publicList.id,
+ public: true,
+ });
+
+ // Create two bookmarks
+ const createBookmark1 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #1",
+ url: "http://nginx:80/hello.html",
+ type: BookmarkTypes.LINK,
+ });
+
+ // Create a second bookmark with an asset
+ const file = new File(["test content"], "test.pdf", {
+ type: "application/pdf",
+ });
+
+ const uploadResponse = await uploadTestAsset(currentApiKey, port, file);
+ const createBookmark2 = await trpcClient.bookmarks.createBookmark.mutate({
+ title: "Test Bookmark #2",
+ type: BookmarkTypes.ASSET,
+ assetType: "pdf",
+ assetId: uploadResponse.assetId,
+ });
+
+ await trpcClient.lists.addToList.mutate({
+ listId: publicList.id,
+ bookmarkId: createBookmark1.id,
+ });
+ await trpcClient.lists.addToList.mutate({
+ listId: publicList.id,
+ bookmarkId: createBookmark2.id,
+ });
+
+ return { publicList, createBookmark1, createBookmark2 };
+ }
+
+ beforeEach(async () => {
+ apiKey = await createTestUser();
+ });
+
+ it("should get public bookmarks", async () => {
+ const { publicList } = await seedDatabase(apiKey);
+ const trpcClient = getTrpcClient(apiKey);
+
+ const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query(
+ {
+ listId: publicList.id,
+ },
+ );
+
+ expect(res.bookmarks.length).toBe(2);
+ });
+
+ it("should be able to access the assets of the public bookmarks", async () => {
+ const { publicList, createBookmark1, createBookmark2 } =
+ await seedDatabase(apiKey);
+
+ const trpcClient = getTrpcClient(apiKey);
+ // Wait for link bookmark to be crawled and have a banner image (screenshot)
+ await waitUntil(
+ async () => {
+ const res = await trpcClient.bookmarks.getBookmark.query({
+ bookmarkId: createBookmark1.id,
+ });
+ assert(res.content.type === BookmarkTypes.LINK);
+ // Check for screenshotAssetId as bannerImageUrl might be derived from it or original imageUrl
+ return !!res.content.screenshotAssetId || !!res.content.imageUrl;
+ },
+ "Bookmark is crawled and has banner info",
+ 20000, // Increased timeout as crawling can take time
+ );
+
+ const res = await trpcClient.publicBookmarks.getPublicBookmarksInList.query(
+ {
+ listId: publicList.id,
+ },
+ );
+
+ const b1Resp = res.bookmarks.find((b) => b.id === createBookmark1.id);
+ expect(b1Resp).toBeDefined();
+ const b2Resp = res.bookmarks.find((b) => b.id === createBookmark2.id);
+ expect(b2Resp).toBeDefined();
+
+ assert(b1Resp!.content.type === BookmarkTypes.LINK);
+ assert(b2Resp!.content.type === BookmarkTypes.ASSET);
+
+ {
+ // Banner image fetch for link bookmark
+ assert(
+ b1Resp!.bannerImageUrl,
+ "Link bookmark should have a bannerImageUrl",
+ );
+ const assetFetch = await fetch(b1Resp!.bannerImageUrl);
+ expect(assetFetch.status).toBe(200);
+ }
+
+ {
+ // Actual asset fetch for asset bookmark
+ assert(
+ b2Resp!.content.assetUrl,
+ "Asset bookmark should have an assetUrl",
+ );
+ const assetFetch = await fetch(b2Resp!.content.assetUrl);
+ expect(assetFetch.status).toBe(200);
+ }
+ });
+
+ it("Accessing non public list should fail", async () => {
+ const trpcClient = getTrpcClient(apiKey);
+ const nonPublicList = await trpcClient.lists.create.mutate({
+ name: "Non Public List",
+ icon: "🚀",
+ type: "manual",
+ });
+
+ await expect(
+ trpcClient.publicBookmarks.getPublicBookmarksInList.query({
+ listId: nonPublicList.id,
+ }),
+ ).rejects.toThrow(/List not found/);
+ });
+
+ describe("Public asset token validation", () => {
+ let userId: string;
+ let assetId: string; // Asset belonging to the primary user (userId)
+
+ beforeEach(async () => {
+ const trpcClient = getTrpcClient(apiKey);
+ const whoami = await trpcClient.users.whoami.query();
+ userId = whoami.id;
+ const assetUpload = await uploadTestAsset(
+ apiKey,
+ port,
+ new File(["test content for token validation"], "token_test.pdf", {
+ type: "application/pdf",
+ }),
+ );
+ assetId = assetUpload.assetId;
+ });
+
+ it("should succeed with a valid token", async () => {
+ const token = createSignedToken(
+ {
+ assetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000, // Expires in 60 seconds
+ );
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(200);
+ expect((await res.blob()).type).toBe("application/pdf");
+ });
+
+ it("should fail without a token", async () => {
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}`,
+ );
+ expect(res.status).toBe(400); // Bad Request due to missing token query param
+ });
+
+ it("should fail with a malformed token string (e.g., not base64)", async () => {
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=thisIsNotValidBase64!@#`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail with a token having a structurally invalid inner payload", async () => {
+ // Payload that doesn't conform to zAssetSignedTokenSchema (e.g. misspelled key)
+ const malformedInnerPayload = {
+ asset_id_mispelled: assetId,
+ userId: userId,
+ };
+ const token = createSignedToken(
+ malformedInnerPayload,
+ Date.now() + 60000,
+ );
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail after token expiry", async () => {
+ const token = createSignedToken(
+ {
+ assetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 1000, // Expires in 1 second
+ );
+
+ // Wait for more than 1 second to ensure expiry
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${token}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail when using a valid token for a different asset", async () => {
+ const anotherAssetUpload = await uploadTestAsset(
+ apiKey, // Same user
+ port,
+ new File(["other content"], "other_asset.pdf", {
+ type: "application/pdf",
+ }),
+ );
+ const anotherAssetId = anotherAssetUpload.assetId;
+
+ // Token is valid for 'anotherAssetId'
+ const tokenForAnotherAsset = createSignedToken(
+ {
+ assetId: anotherAssetId,
+ userId,
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ // Attempt to use this token to access the original 'assetId'
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForAnotherAsset}`,
+ );
+ expect(res.status).toBe(403);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Invalid or expired token" }),
+ );
+ });
+
+ it("should fail if token's userId does not own the requested assetId (expect 404)", async () => {
+ // User1 (primary, `apiKey`, `userId`) owns `assetId` (from beforeEach)
+
+ // Create User2 - ensure unique email for user creation
+ const apiKeyUser2 = await createTestUser();
+ const trpcClientUser2 = getTrpcClient(apiKeyUser2);
+ const whoamiUser2 = await trpcClientUser2.users.whoami.query();
+ const userIdUser2 = whoamiUser2.id;
+
+ // Generate a token where the payload claims assetId is being accessed by userIdUser2,
+ // but assetId actually belongs to the original userId.
+ const tokenForUser2AttemptingAsset1 = createSignedToken(
+ {
+ assetId: assetId, // assetId belongs to user1 (userId)
+ userId: userIdUser2, // token claims user2 is accessing it
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ // User2 attempts to access assetId (owned by User1) using a token that has User2's ID in its payload.
+ // The API route will use userIdUser2 from the token to query the DB for assetId.
+ // Since assetId is not owned by userIdUser2, the DB query will find nothing.
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${assetId}?token=${tokenForUser2AttemptingAsset1}`,
+ );
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Asset not found" }),
+ );
+ });
+
+ it("should fail for a token referencing a non-existent assetId (expect 404)", async () => {
+ const nonExistentAssetId = `nonexistent-asset-${Date.now()}`;
+ const token = createSignedToken(
+ {
+ assetId: nonExistentAssetId,
+ userId, // Valid userId from the primary user
+ } as z.infer<typeof zAssetSignedTokenSchema>,
+ Date.now() + 60000,
+ );
+
+ const res = await fetch(
+ `http://localhost:${port}/api/public/assets/${nonExistentAssetId}?token=${token}`,
+ );
+ expect(res.status).toBe(404);
+ expect(await res.json()).toEqual(
+ expect.objectContaining({ error: "Asset not found" }),
+ );
+ });
+ });
+});
diff --git a/packages/e2e_tests/vitest.config.ts b/packages/e2e_tests/vitest.config.ts
index bb1c7ea4..2735f1e2 100644
--- a/packages/e2e_tests/vitest.config.ts
+++ b/packages/e2e_tests/vitest.config.ts
@@ -14,5 +14,8 @@ export default defineConfig({
teardownTimeout: 30000,
include: ["tests/**/*.test.ts"],
testTimeout: 60000,
+ env: {
+ NEXTAUTH_SECRET: "secret",
+ },
},
});
diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json
index adcfe13a..a8eb2ac2 100644
--- a/packages/open-api/karakeep-openapi-spec.json
+++ b/packages/open-api/karakeep-openapi-spec.json
@@ -426,13 +426,17 @@
"query": {
"type": "string",
"nullable": true
+ },
+ "public": {
+ "type": "boolean"
}
},
"required": [
"id",
"name",
"icon",
- "parentId"
+ "parentId",
+ "public"
]
},
"Tag": {
@@ -1982,6 +1986,9 @@
"query": {
"type": "string",
"minLength": 1
+ },
+ "public": {
+ "type": "boolean"
}
}
}
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index 218b46b0..b899dbeb 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -18,6 +18,7 @@ const optionalStringBool = () =>
const allEnv = z.object({
API_URL: z.string().url().default("http://localhost:3000"),
NEXTAUTH_URL: z.string().url().default("http://localhost:3000"),
+ NEXTAUTH_SECRET: z.string().optional(),
DISABLE_SIGNUPS: stringBool("false"),
DISABLE_PASSWORD_AUTH: stringBool("false"),
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: stringBool("false"),
@@ -94,6 +95,12 @@ const serverConfigSchema = allEnv.transform((val) => {
apiUrl: val.API_URL,
publicUrl: val.NEXTAUTH_URL,
publicApiUrl: `${val.NEXTAUTH_URL}/api`,
+ signingSecret: () => {
+ if (!val.NEXTAUTH_SECRET) {
+ throw new Error("NEXTAUTH_SECRET is not set");
+ }
+ return val.NEXTAUTH_SECRET;
+ },
auth: {
disableSignups: val.DISABLE_SIGNUPS,
disablePasswordAuth: val.DISABLE_PASSWORD_AUTH,
diff --git a/packages/shared/signedTokens.ts b/packages/shared/signedTokens.ts
new file mode 100644
index 00000000..b5e27f3e
--- /dev/null
+++ b/packages/shared/signedTokens.ts
@@ -0,0 +1,71 @@
+import crypto from "node:crypto";
+import { z } from "zod";
+
+import serverConfig from "./config";
+
+const zTokenPayload = z.object({
+ payload: z.unknown(),
+ expiresAt: z.number(),
+});
+
+const zSignedTokenPayload = z.object({
+ payload: zTokenPayload,
+ signature: z.string(),
+});
+
+export type SignedTokenPayload = z.infer<typeof zSignedTokenPayload>;
+
+export function createSignedToken(
+ payload: unknown,
+ expiryEpoch?: number,
+): string {
+ const expiresAt = expiryEpoch ?? Date.now() + 5 * 60 * 1000; // 5 minutes from now
+
+ const toBeSigned: z.infer<typeof zTokenPayload> = {
+ payload,
+ expiresAt,
+ };
+
+ const payloadString = JSON.stringify(toBeSigned);
+ const signature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(payloadString)
+ .digest("hex");
+
+ const tokenData: z.infer<typeof zSignedTokenPayload> = {
+ payload: toBeSigned,
+ signature,
+ };
+
+ return Buffer.from(JSON.stringify(tokenData)).toString("base64");
+}
+
+export function verifySignedToken<T>(
+ token: string,
+ schema: z.ZodSchema<T>,
+): T | null {
+ try {
+ const tokenData = zSignedTokenPayload.parse(
+ JSON.parse(Buffer.from(token, "base64").toString()),
+ );
+ const { payload, signature } = tokenData;
+
+ // Verify signature
+ const expectedSignature = crypto
+ .createHmac("sha256", serverConfig.signingSecret())
+ .update(JSON.stringify(payload))
+ .digest("hex");
+
+ if (signature !== expectedSignature) {
+ return null;
+ }
+ // Check expiry
+ if (Date.now() > payload.expiresAt) {
+ return null;
+ }
+
+ return schema.parse(payload.payload);
+ } catch {
+ return null;
+ }
+}
diff --git a/packages/shared/types/assets.ts b/packages/shared/types/assets.ts
new file mode 100644
index 00000000..fe0adcfd
--- /dev/null
+++ b/packages/shared/types/assets.ts
@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const zAssetSignedTokenSchema = z.object({
+ assetId: z.string(),
+ userId: z.string(),
+});
diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts
index 3522fad3..ea1ab717 100644
--- a/packages/shared/types/bookmarks.ts
+++ b/packages/shared/types/bookmarks.ts
@@ -250,6 +250,7 @@ export const zPublicBookmarkSchema = z.object({
title: z.string().nullish(),
tags: z.array(z.string()),
description: z.string().nullish(),
+ bannerImageUrl: z.string().nullable(),
content: z.discriminatedUnion("type", [
z.object({
type: z.literal(BookmarkTypes.LINK),
@@ -264,6 +265,7 @@ export const zPublicBookmarkSchema = z.object({
type: z.literal(BookmarkTypes.ASSET),
assetType: z.enum(["image", "pdf"]),
assetId: z.string(),
+ assetUrl: z.string(),
fileName: z.string().nullish(),
sourceUrl: z.string().nullish(),
}),
diff --git a/packages/shared/types/lists.ts b/packages/shared/types/lists.ts
index 7ef5687c..51fb458c 100644
--- a/packages/shared/types/lists.ts
+++ b/packages/shared/types/lists.ts
@@ -47,6 +47,7 @@ export const zBookmarkListSchema = z.object({
parentId: z.string().nullable(),
type: z.enum(["manual", "smart"]).default("manual"),
query: z.string().nullish(),
+ public: z.boolean(),
});
export type ZBookmarkList = z.infer<typeof zBookmarkListSchema>;
@@ -66,6 +67,7 @@ export const zEditBookmarkListSchema = z.object({
icon: z.string().optional(),
parentId: z.string().nullish(),
query: z.string().min(1).optional(),
+ public: z.boolean().optional(),
});
export const zEditBookmarkListSchemaWithValidation = zEditBookmarkListSchema
diff --git a/packages/shared/utils/bookmarkUtils.ts b/packages/shared/utils/bookmarkUtils.ts
index 31d7b698..97ef08fc 100644
--- a/packages/shared/utils/bookmarkUtils.ts
+++ b/packages/shared/utils/bookmarkUtils.ts
@@ -3,18 +3,32 @@ import { getAssetUrl } from "./assetUtils";
const MAX_LOADING_MSEC = 30 * 1000;
-export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+export function getBookmarkLinkAssetIdOrUrl(bookmark: ZBookmarkedLink) {
if (bookmark.imageAssetId) {
- return { url: getAssetUrl(bookmark.imageAssetId), localAsset: true };
+ return { assetId: bookmark.imageAssetId, localAsset: true as const };
}
if (bookmark.screenshotAssetId) {
- return { url: getAssetUrl(bookmark.screenshotAssetId), localAsset: true };
+ return { assetId: bookmark.screenshotAssetId, localAsset: true as const };
}
return bookmark.imageUrl
- ? { url: bookmark.imageUrl, localAsset: false }
+ ? { url: bookmark.imageUrl, localAsset: false as const }
: null;
}
+export function getBookmarkLinkImageUrl(bookmark: ZBookmarkedLink) {
+ const assetOrUrl = getBookmarkLinkAssetIdOrUrl(bookmark);
+ if (!assetOrUrl) {
+ return null;
+ }
+ if (!assetOrUrl.localAsset) {
+ return assetOrUrl;
+ }
+ return {
+ url: getAssetUrl(assetOrUrl.assetId),
+ localAsset: true,
+ };
+}
+
export function isBookmarkStillCrawling(bookmark: ZBookmark) {
return (
bookmark.content.type == BookmarkTypes.LINK &&
diff --git a/packages/trpc/models/bookmarks.ts b/packages/trpc/models/bookmarks.ts
index 524749f9..6e9e5651 100644
--- a/packages/trpc/models/bookmarks.ts
+++ b/packages/trpc/models/bookmarks.ts
@@ -27,6 +27,9 @@ import {
rssFeedImportsTable,
tagsOnBookmarks,
} from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
+import { createSignedToken } from "@karakeep/shared/signedTokens";
+import { zAssetSignedTokenSchema } from "@karakeep/shared/types/assets";
import {
BookmarkTypes,
DEFAULT_NUM_BOOKMARKS_PER_PAGE,
@@ -36,7 +39,10 @@ import {
ZPublicBookmark,
} from "@karakeep/shared/types/bookmarks";
import { ZCursor } from "@karakeep/shared/types/pagination";
-import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
+import {
+ getBookmarkLinkAssetIdOrUrl,
+ getBookmarkTitle,
+} from "@karakeep/shared/utils/bookmarkUtils";
import { AuthedContext } from "..";
import { mapDBAssetTypeToUserType } from "../lib/attachments";
@@ -321,6 +327,14 @@ export class Bookmark implements PrivacyAware {
}
asPublicBookmark(): ZPublicBookmark {
+ const getPublicSignedAssetUrl = (assetId: string) => {
+ const payload: z.infer<typeof zAssetSignedTokenSchema> = {
+ assetId,
+ userId: this.ctx.user.id,
+ };
+ const signedToken = createSignedToken(payload);
+ return `${serverConfig.publicApiUrl}/public/assets/${assetId}?token=${signedToken}`;
+ };
const getContent = (
content: ZBookmarkContent,
): ZPublicBookmark["content"] => {
@@ -342,6 +356,7 @@ export class Bookmark implements PrivacyAware {
type: BookmarkTypes.ASSET,
assetType: content.assetType,
assetId: content.assetId,
+ assetUrl: getPublicSignedAssetUrl(content.assetId),
fileName: content.fileName,
sourceUrl: content.sourceUrl,
};
@@ -352,6 +367,47 @@ export class Bookmark implements PrivacyAware {
}
};
+ const getBannerImageUrl = (content: ZBookmarkContent): string | null => {
+ switch (content.type) {
+ case BookmarkTypes.LINK: {
+ const assetIdOrUrl = getBookmarkLinkAssetIdOrUrl(content);
+ if (!assetIdOrUrl) {
+ return null;
+ }
+ if (assetIdOrUrl.localAsset) {
+ return getPublicSignedAssetUrl(assetIdOrUrl.assetId);
+ } else {
+ return assetIdOrUrl.url;
+ }
+ }
+ case BookmarkTypes.TEXT: {
+ return null;
+ }
+ case BookmarkTypes.ASSET: {
+ switch (content.assetType) {
+ case "image":
+ return `${getPublicSignedAssetUrl(content.assetId)}`;
+ case "pdf": {
+ const screenshotAssetId = this.bookmark.assets.find(
+ (r) => r.assetType === "assetScreenshot",
+ )?.id;
+ if (!screenshotAssetId) {
+ return null;
+ }
+ return getPublicSignedAssetUrl(screenshotAssetId);
+ }
+ default: {
+ const _exhaustiveCheck: never = content.assetType;
+ return null;
+ }
+ }
+ }
+ default: {
+ throw new Error("Unknown bookmark content type");
+ }
+ }
+ };
+
// WARNING: Everything below is exposed in the public APIs, don't use spreads!
return {
id: this.bookmark.id,
@@ -360,6 +416,7 @@ export class Bookmark implements PrivacyAware {
title: getBookmarkTitle(this.bookmark),
tags: this.bookmark.tags.map((t) => t.name),
content: getContent(this.bookmark.content),
+ bannerImageUrl: getBannerImageUrl(this.bookmark.content),
};
}
}
diff --git a/packages/trpc/models/lists.ts b/packages/trpc/models/lists.ts
index 4413a8cd..2631ca7e 100644
--- a/packages/trpc/models/lists.ts
+++ b/packages/trpc/models/lists.ts
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { TRPCError } from "@trpc/server";
-import { and, count, eq } from "drizzle-orm";
+import { and, count, eq, or } from "drizzle-orm";
import invariant from "tiny-invariant";
import { z } from "zod";
@@ -8,11 +8,13 @@ import { SqliteError } from "@karakeep/db";
import { bookmarkLists, bookmarksInLists } from "@karakeep/db/schema";
import { triggerRuleEngineOnEvent } from "@karakeep/shared/queues";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
+import { ZSortOrder } from "@karakeep/shared/types/bookmarks";
import {
ZBookmarkList,
zEditBookmarkListSchemaWithValidation,
zNewBookmarkListSchema,
} from "@karakeep/shared/types/lists";
+import { ZCursor } from "@karakeep/shared/types/pagination";
import { AuthedContext, Context } from "..";
import { buildImpersonatingAuthedContext } from "../lib/impersonate";
@@ -61,18 +63,23 @@ export abstract class List implements PrivacyAware {
}
}
- static async getForRss(
+ static async getPublicListContents(
ctx: Context,
listId: string,
- token: string,
+ token: string | null,
pagination: {
limit: number;
+ order: Exclude<ZSortOrder, "relevance">;
+ cursor: ZCursor | null | undefined;
},
) {
const listdb = await ctx.db.query.bookmarkLists.findFirst({
where: and(
eq(bookmarkLists.id, listId),
- eq(bookmarkLists.rssToken, token),
+ or(
+ eq(bookmarkLists.public, true),
+ token !== null ? eq(bookmarkLists.rssToken, token) : undefined,
+ ),
),
});
if (!listdb) {
@@ -85,7 +92,6 @@ export abstract class List implements PrivacyAware {
// The token here acts as an authed context, so we can create
// an impersonating context for the list owner as long as
// we don't leak the context.
-
const authedCtx = await buildImpersonatingAuthedContext(listdb.userId);
const list = List.fromData(authedCtx, listdb);
const bookmarkIds = await list.getBookmarkIds();
@@ -94,7 +100,8 @@ export abstract class List implements PrivacyAware {
ids: bookmarkIds,
includeContent: false,
limit: pagination.limit,
- sortOrder: "desc",
+ sortOrder: pagination.order,
+ cursor: pagination.cursor,
});
return {
@@ -102,8 +109,10 @@ export abstract class List implements PrivacyAware {
icon: list.list.icon,
name: list.list.name,
description: list.list.description,
+ numItems: bookmarkIds.length,
},
bookmarks: bookmarks.bookmarks.map((b) => b.asPublicBookmark()),
+ nextCursor: bookmarks.nextCursor,
};
}
@@ -185,6 +194,7 @@ export abstract class List implements PrivacyAware {
icon: input.icon,
parentId: input.parentId,
query: input.query,
+ public: input.public,
})
.where(
and(
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index 394e95e7..e09f959e 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -7,6 +7,7 @@ import { feedsAppRouter } from "./feeds";
import { highlightsAppRouter } from "./highlights";
import { listsAppRouter } from "./lists";
import { promptsAppRouter } from "./prompts";
+import { publicBookmarks } from "./publicBookmarks";
import { rulesAppRouter } from "./rules";
import { tagsAppRouter } from "./tags";
import { usersAppRouter } from "./users";
@@ -25,6 +26,7 @@ export const appRouter = router({
webhooks: webhooksAppRouter,
assets: assetsAppRouter,
rules: rulesAppRouter,
+ publicBookmarks: publicBookmarks,
});
// export type definition of API
export type AppRouter = typeof appRouter;
diff --git a/packages/trpc/routers/publicBookmarks.ts b/packages/trpc/routers/publicBookmarks.ts
new file mode 100644
index 00000000..6b643354
--- /dev/null
+++ b/packages/trpc/routers/publicBookmarks.ts
@@ -0,0 +1,49 @@
+import { z } from "zod";
+
+import {
+ MAX_NUM_BOOKMARKS_PER_PAGE,
+ zPublicBookmarkSchema,
+ zSortOrder,
+} from "@karakeep/shared/types/bookmarks";
+import { zBookmarkListSchema } from "@karakeep/shared/types/lists";
+import { zCursorV2 } from "@karakeep/shared/types/pagination";
+
+import { publicProcedure, router } from "../index";
+import { List } from "../models/lists";
+
+export const publicBookmarks = router({
+ getPublicBookmarksInList: publicProcedure
+ .input(
+ z.object({
+ listId: z.string(),
+ cursor: zCursorV2.nullish(),
+ limit: z.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).default(20),
+ sortOrder: zSortOrder.exclude(["relevance"]).optional().default("desc"),
+ }),
+ )
+ .output(
+ z.object({
+ list: zBookmarkListSchema
+ .pick({
+ name: true,
+ description: true,
+ icon: true,
+ })
+ .merge(z.object({ numItems: z.number() })),
+ bookmarks: z.array(zPublicBookmarkSchema),
+ nextCursor: zCursorV2.nullable(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ return await List.getPublicListContents(
+ ctx,
+ input.listId,
+ /* token */ null,
+ {
+ limit: input.limit,
+ order: input.sortOrder,
+ cursor: input.cursor,
+ },
+ );
+ }),
+});