aboutsummaryrefslogtreecommitdiffstats
path: root/packages/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'packages/plugins')
-rw-r--r--packages/plugins/package.json1
-rw-r--r--packages/plugins/ratelimit-memory/index.ts13
-rw-r--r--packages/plugins/ratelimit-memory/src/index.test.ts253
-rw-r--r--packages/plugins/ratelimit-memory/src/index.ts86
4 files changed, 353 insertions, 0 deletions
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
index 8b3f73f7..5931d1a7 100644
--- a/packages/plugins/package.json
+++ b/packages/plugins/package.json
@@ -7,6 +7,7 @@
"exports": {
"./queue-liteque": "./queue-liteque/index.ts",
"./queue-restate": "./queue-restate/index.ts",
+ "./ratelimit-memory": "./ratelimit-memory/index.ts",
"./search-meilisearch": "./search-meilisearch/index.ts"
},
"scripts": {
diff --git a/packages/plugins/ratelimit-memory/index.ts b/packages/plugins/ratelimit-memory/index.ts
new file mode 100644
index 00000000..e47a2341
--- /dev/null
+++ b/packages/plugins/ratelimit-memory/index.ts
@@ -0,0 +1,13 @@
+// Auto-register the RateLimit plugin when this package is imported
+import { PluginManager, PluginType } from "@karakeep/shared/plugins";
+
+import { RateLimitProvider } from "./src";
+
+PluginManager.register({
+ type: PluginType.RateLimit,
+ name: "In-Memory Rate Limiter",
+ provider: new RateLimitProvider(),
+});
+
+// Export the provider and rate limiter class for advanced usage
+export { RateLimiter, RateLimitProvider } from "./src";
diff --git a/packages/plugins/ratelimit-memory/src/index.test.ts b/packages/plugins/ratelimit-memory/src/index.test.ts
new file mode 100644
index 00000000..5bbed769
--- /dev/null
+++ b/packages/plugins/ratelimit-memory/src/index.test.ts
@@ -0,0 +1,253 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { RateLimiter } from "./index";
+
+describe("RateLimiter", () => {
+ let rateLimiter: RateLimiter;
+
+ beforeEach(() => {
+ rateLimiter = new RateLimiter();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ rateLimiter.clear();
+ });
+
+ describe("checkRateLimit", () => {
+ it("should allow requests within rate limit", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 3,
+ };
+
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user1");
+ const result3 = rateLimiter.checkRateLimit(config, "user1");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ expect(result3.allowed).toBe(true);
+ });
+
+ it("should block requests exceeding rate limit", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 2,
+ };
+
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user1");
+ const result3 = rateLimiter.checkRateLimit(config, "user1");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ expect(result3.allowed).toBe(false);
+ expect(result3.resetInSeconds).toBeDefined();
+ expect(result3.resetInSeconds).toBeGreaterThan(0);
+ });
+
+ it("should reset after window expires", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 2,
+ };
+
+ // First two requests allowed
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user1");
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+
+ // Third request blocked
+ const result3 = rateLimiter.checkRateLimit(config, "user1");
+ expect(result3.allowed).toBe(false);
+
+ // Advance time past the window
+ vi.advanceTimersByTime(61000);
+
+ // Should allow request after window reset
+ const result4 = rateLimiter.checkRateLimit(config, "user1");
+ expect(result4.allowed).toBe(true);
+ });
+
+ it("should isolate rate limits by identifier", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user2");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ });
+
+ it("should isolate rate limits by key", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ const result1 = rateLimiter.checkRateLimit(config, "user1:/api/v1");
+ const result2 = rateLimiter.checkRateLimit(config, "user1:/api/v2");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ });
+
+ it("should isolate rate limits by config name", () => {
+ const config1 = {
+ name: "api",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+ const config2 = {
+ name: "auth",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ const result1 = rateLimiter.checkRateLimit(config1, "user1");
+ const result2 = rateLimiter.checkRateLimit(config2, "user1");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ });
+
+ it("should calculate correct resetInSeconds", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // First request allowed
+ rateLimiter.checkRateLimit(config, "user1");
+
+ // Advance time by 30 seconds
+ vi.advanceTimersByTime(30000);
+
+ // Second request blocked
+ const result = rateLimiter.checkRateLimit(config, "user1");
+ expect(result.allowed).toBe(false);
+ // Should have ~30 seconds remaining
+ expect(result.resetInSeconds).toBeGreaterThan(29);
+ expect(result.resetInSeconds).toBeLessThanOrEqual(30);
+ });
+ });
+
+ describe("reset", () => {
+ it("should reset rate limit for specific identifier", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // Use up the limit
+ rateLimiter.checkRateLimit(config, "user1");
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ expect(result1.allowed).toBe(false);
+
+ // Reset the limit
+ rateLimiter.reset(config, "user1");
+
+ // Should allow request again
+ const result2 = rateLimiter.checkRateLimit(config, "user1");
+ expect(result2.allowed).toBe(true);
+ });
+
+ it("should reset rate limit for specific key", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // Use up the limit for key1
+ rateLimiter.checkRateLimit(config, "user1:/path1");
+ const result1 = rateLimiter.checkRateLimit(config, "user1:/path1");
+ expect(result1.allowed).toBe(false);
+
+ // Reset only key1
+ rateLimiter.reset(config, "user1:/path1");
+
+ // key1 should be allowed
+ const result2 = rateLimiter.checkRateLimit(config, "user1:/path1");
+ expect(result2.allowed).toBe(true);
+ });
+
+ it("should not affect other identifiers", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // Use up limits for both users
+ rateLimiter.checkRateLimit(config, "user1");
+ rateLimiter.checkRateLimit(config, "user2");
+
+ // Reset only user1
+ rateLimiter.reset(config, "user1");
+
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user2");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(false);
+ });
+ });
+
+ describe("clear", () => {
+ it("should clear all rate limits", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // Use up limits for multiple users
+ rateLimiter.checkRateLimit(config, "user1");
+ rateLimiter.checkRateLimit(config, "user2");
+
+ // Clear all limits
+ rateLimiter.clear();
+
+ // All should be allowed
+ const result1 = rateLimiter.checkRateLimit(config, "user1");
+ const result2 = rateLimiter.checkRateLimit(config, "user2");
+
+ expect(result1.allowed).toBe(true);
+ expect(result2.allowed).toBe(true);
+ });
+ });
+
+ describe("cleanup", () => {
+ it("should cleanup expired entries", () => {
+ const config = {
+ name: "test",
+ windowMs: 60000,
+ maxRequests: 1,
+ };
+
+ // Create an entry
+ rateLimiter.checkRateLimit(config, "user1");
+
+ // Advance time past window + cleanup interval
+ vi.advanceTimersByTime(61000 + 60000);
+
+ // Entry should be cleaned up and new request allowed
+ const result = rateLimiter.checkRateLimit(config, "user1");
+ expect(result.allowed).toBe(true);
+ });
+ });
+});
diff --git a/packages/plugins/ratelimit-memory/src/index.ts b/packages/plugins/ratelimit-memory/src/index.ts
new file mode 100644
index 00000000..c47889d3
--- /dev/null
+++ b/packages/plugins/ratelimit-memory/src/index.ts
@@ -0,0 +1,86 @@
+import type {
+ RateLimitClient,
+ RateLimitConfig,
+ RateLimitResult,
+} from "@karakeep/shared/ratelimiting";
+import { PluginProvider } from "@karakeep/shared/plugins";
+
+interface RateLimitEntry {
+ count: number;
+ resetTime: number;
+}
+
+export class RateLimiter implements RateLimitClient {
+ private store = new Map<string, RateLimitEntry>();
+ private cleanupProbability: number;
+
+ constructor(cleanupProbability = 0.01) {
+ // Probability of cleanup on each check (default 1%)
+ this.cleanupProbability = cleanupProbability;
+ }
+
+ private cleanupExpiredEntries() {
+ const now = Date.now();
+ for (const [key, entry] of this.store.entries()) {
+ if (now > entry.resetTime) {
+ this.store.delete(key);
+ }
+ }
+ }
+
+ checkRateLimit(config: RateLimitConfig, key: string): RateLimitResult {
+ if (!key) {
+ return { allowed: true };
+ }
+
+ // Probabilistic cleanup
+ if (Math.random() < this.cleanupProbability) {
+ this.cleanupExpiredEntries();
+ }
+
+ const rateLimitKey = `${config.name}:${key}`;
+ const now = Date.now();
+
+ let entry = this.store.get(rateLimitKey);
+
+ if (!entry || now > entry.resetTime) {
+ entry = {
+ count: 1,
+ resetTime: now + config.windowMs,
+ };
+ this.store.set(rateLimitKey, entry);
+ return { allowed: true };
+ }
+
+ if (entry.count >= config.maxRequests) {
+ const resetInSeconds = Math.ceil((entry.resetTime - now) / 1000);
+ return {
+ allowed: false,
+ resetInSeconds,
+ };
+ }
+
+ entry.count++;
+ return { allowed: true };
+ }
+
+ reset(config: RateLimitConfig, key: string) {
+ const rateLimitKey = `${config.name}:${key}`;
+ this.store.delete(rateLimitKey);
+ }
+
+ clear() {
+ this.store.clear();
+ }
+}
+
+export class RateLimitProvider implements PluginProvider<RateLimitClient> {
+ private client: RateLimiter | null = null;
+
+ async getClient(): Promise<RateLimitClient | null> {
+ if (!this.client) {
+ this.client = new RateLimiter();
+ }
+ return this.client;
+ }
+}