From 74df8bd789ee2d56d0620e9852aa3eb7c48f0823 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 13 Dec 2025 14:36:37 +0000 Subject: feat: Add limits on number of rss feeds and webhooks per user --- .../03-configuration/01-environment-variables.md | 6 +- packages/shared/config.ts | 6 + packages/trpc/models/feeds.ts | 17 ++- packages/trpc/models/webhooks.ts | 17 ++- packages/trpc/routers/feeds.test.ts | 154 +++++++++++++++++++++ packages/trpc/routers/webhooks.test.ts | 22 +++ 6 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 packages/trpc/routers/feeds.test.ts 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, ): Promise { + // 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, ): Promise { + // 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(defaultBeforeEach(true)); + +describe("Feed Routes", () => { + test("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("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("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("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("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("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("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/); + }); }); -- cgit v1.2.3-70-g09d2