diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/shared/config.ts | 6 | ||||
| -rw-r--r-- | packages/trpc/models/feeds.ts | 17 | ||||
| -rw-r--r-- | packages/trpc/models/webhooks.ts | 17 | ||||
| -rw-r--r-- | packages/trpc/routers/feeds.test.ts | 154 | ||||
| -rw-r--r-- | packages/trpc/routers/webhooks.test.ts | 22 |
5 files changed, 214 insertions, 2 deletions
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/); + }); }); |
