aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-12-13 14:36:37 +0000
committerMohamed Bassem <me@mbassem.com>2025-12-13 14:36:37 +0000
commit74df8bd789ee2d56d0620e9852aa3eb7c48f0823 (patch)
treeda01c8de0736c0eb9a6662121014999cd23e99c7
parent697c853a7dac003b9636eb73433658ad89ec113e (diff)
downloadkarakeep-74df8bd789ee2d56d0620e9852aa3eb7c48f0823.tar.zst
feat: Add limits on number of rss feeds and webhooks per user
-rw-r--r--docs/docs/03-configuration/01-environment-variables.md6
-rw-r--r--packages/shared/config.ts6
-rw-r--r--packages/trpc/models/feeds.ts17
-rw-r--r--packages/trpc/models/webhooks.ts17
-rw-r--r--packages/trpc/routers/feeds.test.ts154
-rw-r--r--packages/trpc/routers/webhooks.test.ts22
6 files changed, 218 insertions, 4 deletions
diff --git a/docs/docs/03-configuration/01-environment-variables.md b/docs/docs/03-configuration/01-environment-variables.md
index ff9721e8..61612479 100644
--- a/docs/docs/03-configuration/01-environment-variables.md
+++ b/docs/docs/03-configuration/01-environment-variables.md
@@ -29,6 +29,8 @@ The app is mainly configured by environment variables. All the used environment
| ASSET_PREPROCESSING_NUM_WORKERS | No | 1 | Number of concurrent workers for asset preprocessing tasks (image processing, OCR, etc.). Increase this if you have many images or documents that need processing. |
| ASSET_PREPROCESSING_JOB_TIMEOUT_SEC | No | 60 | How long to wait for an asset preprocessing job to finish before timing out. Increase this if you have large images or PDFs that take longer to process. |
| RULE_ENGINE_NUM_WORKERS | No | 1 | Number of concurrent workers for rule engine processing. Increase this if you have complex automation rules that need to be processed quickly. |
+| MAX_RSS_FEEDS_PER_USER | No | 1000 | The maximum number of RSS feeds a user can create. |
+| MAX_WEBHOOKS_PER_USER | No | 100 | The maximum number of webhooks a user can create. |
## Asset Storage
@@ -184,8 +186,8 @@ Karakeep uses [tesseract.js](https://github.com/naptha/tesseract.js) to extract
You can use webhooks to trigger actions when bookmarks are created, changed or crawled.
-| Name | Required | Default | Description |
-| ------------------- | -------- | ------- | ------------------------------------------------- |
+| Name | Required | Default | Description |
+| ------------------- | -------- | ------- | -------------------------------------------------- |
| WEBHOOK_TIMEOUT_SEC | No | 5 | The timeout for the webhook request in seconds. |
| WEBHOOK_RETRY_TIMES | No | 3 | The number of times to retry the webhook request. |
diff --git a/packages/shared/config.ts b/packages/shared/config.ts
index 1bc8f19d..b8809ded 100644
--- a/packages/shared/config.ts
+++ b/packages/shared/config.ts
@@ -123,6 +123,8 @@ const allEnv = z.object({
INFERENCE_LANG: z.string().default("english"),
WEBHOOK_TIMEOUT_SEC: z.coerce.number().default(5),
WEBHOOK_RETRY_TIMES: z.coerce.number().int().min(0).default(3),
+ MAX_RSS_FEEDS_PER_USER: z.coerce.number().default(1000),
+ MAX_WEBHOOKS_PER_USER: z.coerce.number().default(100),
// Build only flag
SERVER_VERSION: z.string().optional(),
DISABLE_NEW_RELEASE_CHECK: stringBool("false"),
@@ -345,6 +347,10 @@ const serverConfigSchema = allEnv.transform((val, ctx) => {
timeoutSec: val.WEBHOOK_TIMEOUT_SEC,
retryTimes: val.WEBHOOK_RETRY_TIMES,
numWorkers: val.WEBHOOK_NUM_WORKERS,
+ maxWebhooksPerUser: val.MAX_WEBHOOKS_PER_USER,
+ },
+ feeds: {
+ maxRssFeedsPerUser: val.MAX_RSS_FEEDS_PER_USER,
},
proxy: {
httpProxy: val.CRAWLER_HTTP_PROXY,
diff --git a/packages/trpc/models/feeds.ts b/packages/trpc/models/feeds.ts
index c0828bbf..ea22da8f 100644
--- a/packages/trpc/models/feeds.ts
+++ b/packages/trpc/models/feeds.ts
@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
+import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import { rssFeedsTable } from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
import {
zFeedSchema,
zNewFeedSchema,
@@ -44,6 +45,20 @@ export class Feed {
ctx: AuthedContext,
input: z.infer<typeof zNewFeedSchema>,
): Promise<Feed> {
+ // Check if user has reached the maximum number of feeds
+ const [feedCount] = await ctx.db
+ .select({ count: count() })
+ .from(rssFeedsTable)
+ .where(eq(rssFeedsTable.userId, ctx.user.id));
+
+ const maxFeeds = serverConfig.feeds.maxRssFeedsPerUser;
+ if (feedCount.count >= maxFeeds) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Maximum number of RSS feeds (${maxFeeds}) reached`,
+ });
+ }
+
const [result] = await ctx.db
.insert(rssFeedsTable)
.values({
diff --git a/packages/trpc/models/webhooks.ts b/packages/trpc/models/webhooks.ts
index d2d9c19c..12281ec7 100644
--- a/packages/trpc/models/webhooks.ts
+++ b/packages/trpc/models/webhooks.ts
@@ -1,8 +1,9 @@
import { TRPCError } from "@trpc/server";
-import { and, eq } from "drizzle-orm";
+import { and, count, eq } from "drizzle-orm";
import { z } from "zod";
import { webhooksTable } from "@karakeep/db/schema";
+import serverConfig from "@karakeep/shared/config";
import {
zNewWebhookSchema,
zUpdateWebhookSchema,
@@ -44,6 +45,20 @@ export class Webhook {
ctx: AuthedContext,
input: z.infer<typeof zNewWebhookSchema>,
): Promise<Webhook> {
+ // Check if user has reached the maximum number of webhooks
+ const [webhookCount] = await ctx.db
+ .select({ count: count() })
+ .from(webhooksTable)
+ .where(eq(webhooksTable.userId, ctx.user.id));
+
+ const maxWebhooks = serverConfig.webhook.maxWebhooksPerUser;
+ if (webhookCount.count >= maxWebhooks) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: `Maximum number of webhooks (${maxWebhooks}) reached`,
+ });
+ }
+
const [result] = await ctx.db
.insert(webhooksTable)
.values({
diff --git a/packages/trpc/routers/feeds.test.ts b/packages/trpc/routers/feeds.test.ts
new file mode 100644
index 00000000..e80aab0a
--- /dev/null
+++ b/packages/trpc/routers/feeds.test.ts
@@ -0,0 +1,154 @@
+import { beforeEach, describe, expect, test } from "vitest";
+
+import type { CustomTestContext } from "../testUtils";
+import { defaultBeforeEach } from "../testUtils";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(true));
+
+describe("Feed Routes", () => {
+ test<CustomTestContext>("create feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+ const newFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ expect(newFeed).toBeDefined();
+ expect(newFeed.name).toEqual("Test Feed");
+ expect(newFeed.url).toEqual("https://example.com/feed.xml");
+ expect(newFeed.enabled).toBe(true);
+ });
+
+ test<CustomTestContext>("update feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // First, create a feed to update
+ const createdFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ // Update it
+ const updatedFeed = await api.update({
+ feedId: createdFeed.id,
+ name: "Updated Feed",
+ url: "https://updated-example.com/feed.xml",
+ enabled: false,
+ });
+
+ expect(updatedFeed.name).toEqual("Updated Feed");
+ expect(updatedFeed.url).toEqual("https://updated-example.com/feed.xml");
+ expect(updatedFeed.enabled).toBe(false);
+
+ // Test updating a non-existent feed
+ await expect(() =>
+ api.update({
+ feedId: "non-existent-id",
+ name: "Fail",
+ url: "https://fail.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Feed not found/);
+ });
+
+ test<CustomTestContext>("list feeds", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create a couple of feeds
+ await api.create({
+ name: "Feed 1",
+ url: "https://example1.com/feed.xml",
+ enabled: true,
+ });
+ await api.create({
+ name: "Feed 2",
+ url: "https://example2.com/feed.xml",
+ enabled: true,
+ });
+
+ const result = await api.list();
+ expect(result.feeds).toBeDefined();
+ expect(result.feeds.length).toBeGreaterThanOrEqual(2);
+ expect(result.feeds.some((f) => f.name === "Feed 1")).toBe(true);
+ expect(result.feeds.some((f) => f.name === "Feed 2")).toBe(true);
+ });
+
+ test<CustomTestContext>("delete feed", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create a feed to delete
+ const createdFeed = await api.create({
+ name: "Test Feed",
+ url: "https://example.com/feed.xml",
+ enabled: true,
+ });
+
+ // Delete it
+ await api.delete({ feedId: createdFeed.id });
+
+ // Verify it's deleted
+ await expect(() =>
+ api.update({
+ feedId: createdFeed.id,
+ name: "Updated",
+ url: "https://updated.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Feed not found/);
+ });
+
+ test<CustomTestContext>("privacy for feeds", async ({ apiCallers }) => {
+ const user1Feed = await apiCallers[0].feeds.create({
+ name: "User 1 Feed",
+ url: "https://user1-feed.com/feed.xml",
+ enabled: true,
+ });
+ const user2Feed = await apiCallers[1].feeds.create({
+ name: "User 2 Feed",
+ url: "https://user2-feed.com/feed.xml",
+ enabled: true,
+ });
+
+ // User 1 should not access User 2's feed
+ await expect(() =>
+ apiCallers[0].feeds.delete({ feedId: user2Feed.id }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+ await expect(() =>
+ apiCallers[0].feeds.update({
+ feedId: user2Feed.id,
+ name: "Fail",
+ url: "https://fail.com",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/User is not allowed to access resource/);
+
+ // List should only show the correct user's feeds
+ const user1List = await apiCallers[0].feeds.list();
+ expect(user1List.feeds.some((f) => f.id === user1Feed.id)).toBe(true);
+ expect(user1List.feeds.some((f) => f.id === user2Feed.id)).toBe(false);
+ });
+
+ test<CustomTestContext>("feed limit enforcement", async ({ apiCallers }) => {
+ const api = apiCallers[0].feeds;
+
+ // Create 1000 feeds (the maximum)
+ for (let i = 0; i < 1000; i++) {
+ await api.create({
+ name: `Feed ${i}`,
+ url: `https://example${i}.com/feed.xml`,
+ enabled: true,
+ });
+ }
+
+ // The 1001st feed should fail
+ await expect(() =>
+ api.create({
+ name: "Feed 1001",
+ url: "https://example1001.com/feed.xml",
+ enabled: true,
+ }),
+ ).rejects.toThrow(/Maximum number of RSS feeds \(1000\) reached/);
+ });
+});
diff --git a/packages/trpc/routers/webhooks.test.ts b/packages/trpc/routers/webhooks.test.ts
index 5a136a31..de27b11e 100644
--- a/packages/trpc/routers/webhooks.test.ts
+++ b/packages/trpc/routers/webhooks.test.ts
@@ -125,4 +125,26 @@ describe("Webhook Routes", () => {
false,
);
});
+
+ test<CustomTestContext>("webhook limit enforcement", async ({
+ apiCallers,
+ }) => {
+ const api = apiCallers[0].webhooks;
+
+ // Create 100 webhooks (the maximum)
+ for (let i = 0; i < 100; i++) {
+ await api.create({
+ url: `https://example${i}.com/webhook`,
+ events: ["created"],
+ });
+ }
+
+ // The 101st webhook should fail
+ await expect(() =>
+ api.create({
+ url: "https://example101.com/webhook",
+ events: ["created"],
+ }),
+ ).rejects.toThrow(/Maximum number of webhooks \(100\) reached/);
+ });
});