diff --git a/.env.example b/.env.example index 52f2cb4..a82723e 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,6 @@ CREATE_RATE_WINDOW_MS=600000 # TRUSTED_PROXY_HOPS=1 # Force Secure cookies (default: derive from PAPERCUT_PUBLIC_URL / NODE_ENV) # COOKIE_SECURE=1 + +# Optional Redis for multi-node rate limits (falls back to in-memory) +# REDIS_URL=redis://localhost:6379 diff --git a/CHANGELOG.md b/CHANGELOG.md index 083e8f7..24cdb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log canvas **multi-paste compare** (side-by-side via `?compare=` or sidebar) - Proxy-aware runtime: `TRUSTED_PROXY_HOPS` for rate-limit client keys; Secure cookies from `PAPERCUT_PUBLIC_URL` / `COOKIE_SECURE` - Docker Compose **profiles**: `proxy` (Caddy + ACME), `sweeper` (periodic `/api/health` purge) +- Optional **Redis** rate limiting via `REDIS_URL` (multi-node; memory fallback) +- CLI **streaming upload** (`text/plain` body + headers); API accepts raw body with size limit ### Fixed diff --git a/README.md b/README.md index 5e91469..078e611 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ See [`.env.example`](./.env.example). | `PAPERCUT_METRICS` | Enable `GET /api/metrics` (`1`/`true`/`yes`/`on`) | off | | `TRUSTED_PROXY_HOPS` | X-Forwarded-For hops to trust (0 = ignore XFF) | `1` | | `COOKIE_SECURE` | Force Secure cookies (`1`/`0`); else from public URL | auto | +| `REDIS_URL` | Shared rate limits across instances (optional) | off (in-memory) | --- @@ -160,7 +161,7 @@ See [`.env.example`](./.env.example). | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/pastes` | `{ content, expire?, password? }` → `{ id, url, expiresAt, metadata }` | +| `POST` | `/api/pastes` | JSON `{ content, expire?, password? }` **or** `text/plain` body + `X-PaperCut-Expire` / `X-PaperCut-Password` headers (streaming CLI) | | `GET` | `/api/pastes/:id` | Paste body or `401` + `{ locked: true }` | | `POST` | `/api/pastes/:id/unlock` | `{ password }` → httpOnly unlock cookie | | `GET` | `/api/health` | Liveness/readiness (+ expired purge) | diff --git a/ROADMAP.md b/ROADMAP.md index e452b23..8ab790a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -91,9 +91,9 @@ Focus: multi-instance self-host without losing privacy defaults. | # | Feature | Area | Notes | |---|---------|------|--------| -| 1.3.1 | **Shared rate-limit store** (Redis optional) | server, docker | Fallback: in-memory | -| 1.3.2 | **Object storage for large pastes** (S3/MinIO optional) | server | Keep SQLite for metadata | -| 1.3.3 | **Streaming upload** (chunked stdin) | cli, api | Very large builds | +| 1.3.1 | **Shared rate-limit store** (Redis optional) | server, docker | ✅ Done (`REDIS_URL`, fixed-window; memory fallback) | +| 1.3.2 | **Object storage for large pastes** (S3/MinIO optional) | server | Deferred (SQLite default remains happy path) | +| 1.3.3 | **Streaming upload** (chunked stdin) | cli, api | ✅ Done (CLI streams `text/plain`; server size-capped read) | | 1.3.4 | **Background expire sweeper** (cron process) | server | ✅ Done (compose profile `sweeper` → `/api/health`) | | 1.3.5 | **Read replicas / RO mode** | server | Optional — deferred (complexity vs single-node default) | | 1.3.6 | **Optional reverse-proxy stack** (compose profiles) | docker | ✅ Done (Caddy profile `proxy` + ACME) | diff --git a/cli/bin.js b/cli/bin.js index 87f713f..a81951e 100755 --- a/cli/bin.js +++ b/cli/bin.js @@ -220,6 +220,21 @@ function printCard(url, meta) { * @param {string} [opts.password] * @param {typeof fetch} [opts.fetchImpl] */ +/** + * Upload paste content. + * Prefer text/plain streaming body (lower memory) when `streamBody` is set; + * otherwise classic JSON `{ content, expire?, password? }`. + * + * @param {{ + * baseUrl: string, + * content?: string, + * streamBody?: import('node:stream').Readable | ReadableStream, + * contentLength?: number, + * expire?: string, + * password?: string, + * fetchImpl?: typeof fetch, + * }} opts + */ async function uploadPaste(opts) { const fetchImpl = opts.fetchImpl || globalThis.fetch; if (typeof fetchImpl !== "function") { @@ -227,15 +242,36 @@ async function uploadPaste(opts) { } /** @type {Record} */ - const body = { content: opts.content }; - if (opts.expire) body.expire = opts.expire; - if (opts.password) body.password = opts.password; - - const res = await fetchImpl(`${opts.baseUrl}/api/pastes`, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify(body), - }); + const headers = { Accept: "application/json" }; + /** @type {BodyInit} */ + let body; + + if (opts.streamBody) { + headers["Content-Type"] = "text/plain; charset=utf-8"; + if (opts.expire) headers["X-PaperCut-Expire"] = opts.expire; + if (opts.password) headers["X-PaperCut-Password"] = opts.password; + if (opts.contentLength != null) { + headers["Content-Length"] = String(opts.contentLength); + } + // Node fetch accepts Node Readable / web streams as duplex body + body = /** @type {any} */ (opts.streamBody); + } else { + headers["Content-Type"] = "application/json"; + /** @type {Record} */ + const json = { content: opts.content || "" }; + if (opts.expire) json.expire = opts.expire; + if (opts.password) json.password = opts.password; + body = JSON.stringify(json); + } + + /** @type {RequestInit} */ + const init = { method: "POST", headers, body }; + if (opts.streamBody) { + // Required by Node undici when body is a stream + /** @type {any} */ (init).duplex = "half"; + } + + const res = await fetchImpl(`${opts.baseUrl}/api/pastes`, init); /** @type {any} */ let data = null; @@ -281,15 +317,10 @@ async function main() { exit(1); } - const content = await readStdin(); - if (!content || content.length === 0) { - stderr.write("Error: empty input\n"); - exit(1); - } - /** @type {string | undefined} */ let password; if (opts.private) { + // Must read password before consuming stdin stream for --private password = env.PAPERCUT_PASSWORD || (await promptHidden("Paste password: ")); if (!password) { stderr.write("Error: password must not be empty\n"); @@ -298,9 +329,10 @@ async function main() { } try { + // Stream stdin as text/plain (no full JSON stringify of large logs) const data = await uploadPaste({ baseUrl: opts.url, - content, + streamBody: stdin, expire: opts.expire, password, }); diff --git a/cli/test/upload.test.js b/cli/test/upload.test.js index a047137..7891e65 100644 --- a/cli/test/upload.test.js +++ b/cli/test/upload.test.js @@ -75,4 +75,37 @@ describe("uploadPaste", () => { /missing url\/id/, ); }); + + it("posts text/plain stream with papercut headers", async () => { + /** @type {any} */ + let captured; + const fetchImpl = mock.fn(async (url, init) => { + captured = { url, init }; + return { + ok: true, + status: 201, + text: async () => + JSON.stringify({ + id: "streamid0001", + url: "http://localhost:3000/paste/streamid0001", + expiresAt: null, + }), + }; + }); + + const streamBody = { fake: "readable" }; + await uploadPaste({ + baseUrl: "http://localhost:3000", + streamBody, + expire: "1d", + password: "pw", + fetchImpl, + }); + + assert.equal(captured.init.headers["Content-Type"], "text/plain; charset=utf-8"); + assert.equal(captured.init.headers["X-PaperCut-Expire"], "1d"); + assert.equal(captured.init.headers["X-PaperCut-Password"], "pw"); + assert.equal(captured.init.body, streamBody); + assert.equal(captured.init.duplex, "half"); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60be980..852a300 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) + redis: + specifier: ^6.1.0 + version: 6.1.0 devDependencies: '@eslint/eslintrc': specifier: ^3.3.6 @@ -837,6 +840,42 @@ packages: '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@redis/bloom@6.1.0': + resolution: {integrity: sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/client@6.1.0': + resolution: {integrity: sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.1.0': + resolution: {integrity: sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/search@6.1.0': + resolution: {integrity: sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + + '@redis/time-series@6.1.0': + resolution: {integrity: sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.1.0 + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -1409,6 +1448,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2477,6 +2520,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redis@6.1.0: + resolution: {integrity: sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==} + engines: {node: '>= 20.0.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3401,6 +3448,26 @@ snapshots: '@petamoriken/float16@3.9.3': optional: true + '@redis/bloom@6.1.0(@redis/client@6.1.0)': + dependencies: + '@redis/client': 6.1.0 + + '@redis/client@6.1.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@6.1.0(@redis/client@6.1.0)': + dependencies: + '@redis/client': 6.1.0 + + '@redis/search@6.1.0(@redis/client@6.1.0)': + dependencies: + '@redis/client': 6.1.0 + + '@redis/time-series@6.1.0(@redis/client@6.1.0)': + dependencies: + '@redis/client': 6.1.0 + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -3960,6 +4027,8 @@ snapshots: client-only@0.0.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5141,6 +5210,17 @@ snapshots: dependencies: picomatch: 2.3.2 + redis@6.1.0: + dependencies: + '@redis/bloom': 6.1.0(@redis/client@6.1.0) + '@redis/client': 6.1.0 + '@redis/json': 6.1.0(@redis/client@6.1.0) + '@redis/search': 6.1.0(@redis/client@6.1.0) + '@redis/time-series': 6.1.0(@redis/client@6.1.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 diff --git a/server/app/api/api.integration.test.ts b/server/app/api/api.integration.test.ts index 069b22d..b417747 100644 --- a/server/app/api/api.integration.test.ts +++ b/server/app/api/api.integration.test.ts @@ -195,7 +195,9 @@ describe("API integration", () => { it("rate-limits unlock attempts with 429", async () => { const tiny = new RateLimiter({ limit: 2, windowMs: 600_000 }); - vi.spyOn(rateLimitMod, "getUnlockRateLimiter").mockReturnValue(tiny); + vi.spyOn(rateLimitMod, "checkRateLimit").mockImplementation( + async (_scope, key) => tiny.attempt(key), + ); const created = await createPasteRoute( new Request("http://test.local/api/pastes", { @@ -236,6 +238,29 @@ describe("API integration", () => { expect(limited.headers.get("Retry-After")).toBeTruthy(); }); + it("accepts text/plain streaming body for create", async () => { + const res = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { + "Content-Type": "text/plain; charset=utf-8", + "X-PaperCut-Expire": "1h", + }, + body: "streamed line 1\nstreamed line 2", + }), + ); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.id).toBeTruthy(); + + const getRes = await getPasteRoute( + new Request(`http://test.local/api/pastes/${data.id}`), + { params: Promise.resolve({ id: data.id }) }, + ); + const paste = await getRes.json(); + expect(paste.rawContent).toBe("streamed line 1\nstreamed line 2"); + }); + it("GET /api/metrics is 404 when disabled (default)", async () => { delete process.env.PAPERCUT_METRICS; const res = await metricsRoute(); @@ -266,7 +291,9 @@ describe("API integration", () => { expect(unlock.status).toBe(200); const tiny = new RateLimiter({ limit: 1, windowMs: 600_000 }); - vi.spyOn(rateLimitMod, "getCreateRateLimiter").mockReturnValue(tiny); + vi.spyOn(rateLimitMod, "checkRateLimit").mockImplementation( + async (_scope, key) => tiny.attempt(key), + ); // First create attempt succeeds under the tiny limiter and is counted; // second hits 429. await createPasteRoute( diff --git a/server/app/api/pastes/[id]/unlock/route.ts b/server/app/api/pastes/[id]/unlock/route.ts index b469b95..de46a10 100644 --- a/server/app/api/pastes/[id]/unlock/route.ts +++ b/server/app/api/pastes/[id]/unlock/route.ts @@ -7,10 +7,7 @@ import { import { getDb } from "@/lib/db"; import { recordMetric } from "@/lib/metrics"; import { unlockPaste } from "@/lib/paste"; -import { - clientKeyFromRequest, - getUnlockRateLimiter, -} from "@/lib/rate-limit"; +import { checkRateLimit, clientKeyFromRequest } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -24,7 +21,7 @@ export async function POST(request: Request, context: RouteContext) { // Key by paste + client so brute force is limited per paste without logging IPs const rateKey = `unlock:${id}:${clientKeyFromRequest(request)}`; - const rate = getUnlockRateLimiter().attempt(rateKey); + const rate = await checkRateLimit("unlock", rateKey); if (!rate.allowed) { recordMetric("rate_limited"); return NextResponse.json( diff --git a/server/app/api/pastes/route.ts b/server/app/api/pastes/route.ts index 2983078..7bde642 100644 --- a/server/app/api/pastes/route.ts +++ b/server/app/api/pastes/route.ts @@ -4,9 +4,10 @@ import { getDb } from "@/lib/db"; import { recordMetric } from "@/lib/metrics"; import { createPaste } from "@/lib/paste"; import { + checkRateLimit, clientKeyFromRequest, - getCreateRateLimiter, } from "@/lib/rate-limit"; +import { getMaxPasteSize } from "@/lib/env"; import { buildPasteUrl } from "@/lib/urls"; export const runtime = "nodejs"; @@ -17,8 +18,9 @@ interface CreateBody { password?: unknown; } -export async function POST(request: Request) { - const rate = getCreateRateLimiter().attempt( +async function rateLimitCreate(request: Request) { + const rate = await checkRateLimit( + "create", `create:${clientKeyFromRequest(request)}`, ); if (!rate.allowed) { @@ -31,6 +33,62 @@ export async function POST(request: Request) { }, ); } + return null; +} + +function createResponse( + request: Request, + result: ReturnType, +) { + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + recordMetric("pastes_created"); + const url = buildPasteUrl(result.id, request.url); + return NextResponse.json( + { + id: result.id, + url, + expiresAt: result.expiresAt, + metadata: result.metadata, + }, + { status: 201 }, + ); +} + +/** + * Create a paste. + * - `application/json` — classic `{ content, expire?, password? }` + * - `text/plain` (or octet-stream) — stream body as content; optional headers + * `X-PaperCut-Expire`, `X-PaperCut-Password` for large stdin without JSON wrap + */ +export async function POST(request: Request) { + const limited = await rateLimitCreate(request); + if (limited) return limited; + + const contentType = request.headers.get("content-type") ?? ""; + const db = getDb(); + purgeExpiredPastes(db); + + // Streaming / raw body path (CLI large stdin) + if ( + contentType.includes("text/plain") || + contentType.includes("application/octet-stream") + ) { + const max = getMaxPasteSize(); + const buf = await readBodyWithLimit(request, max); + if (!buf.ok) { + return NextResponse.json({ error: buf.error }, { status: buf.status }); + } + const expire = request.headers.get("x-papercut-expire") ?? undefined; + const password = request.headers.get("x-papercut-password") ?? undefined; + const result = createPaste(db, { + content: buf.text, + expire: expire || undefined, + password: password || undefined, + }); + return createResponse(request, result); + } let body: CreateBody; try { @@ -60,29 +118,65 @@ export async function POST(request: Request) { ); } - const db = getDb(); - purgeExpiredPastes(db); - const result = createPaste(db, { content: body.content, expire: body.expire as string | undefined, password: body.password as string | undefined, }); - if (!result.ok) { - return NextResponse.json({ error: result.error }, { status: result.status }); + return createResponse(request, result); +} + +async function readBodyWithLimit( + request: Request, + maxBytes: number, +): Promise< + | { ok: true; text: string } + | { ok: false; status: number; error: string } +> { + const cl = request.headers.get("content-length"); + if (cl) { + const n = Number.parseInt(cl, 10); + if (Number.isFinite(n) && n > maxBytes) { + return { + ok: false, + status: 413, + error: `content exceeds MAX_PASTE_SIZE (${maxBytes} bytes)`, + }; + } } - recordMetric("pastes_created"); - const url = buildPasteUrl(result.id, request.url); + if (!request.body) { + return { ok: true, text: "" }; + } - return NextResponse.json( - { - id: result.id, - url, - expiresAt: result.expiresAt, - metadata: result.metadata, - }, - { status: 201 }, - ); + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch { + /* ignore */ + } + return { + ok: false, + status: 413, + error: `content exceeds MAX_PASTE_SIZE (${maxBytes} bytes)`, + }; + } + chunks.push(value); + } + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.byteLength; + } + return { ok: true, text: new TextDecoder("utf-8").decode(merged) }; } diff --git a/server/lib/rate-limit.ts b/server/lib/rate-limit.ts index 332e4c4..c9e2de8 100644 --- a/server/lib/rate-limit.ts +++ b/server/lib/rate-limit.ts @@ -1,6 +1,6 @@ /** - * Simple in-memory sliding-window rate limiter. - * Suitable for single-node / Docker deploys. Keys are never written to logs. + * Sliding-window rate limiter (in-memory) with optional Redis fixed-window backend. + * Keys are never written to application logs. */ import { getTrustedProxyHops } from "./env"; @@ -21,6 +21,8 @@ export interface RateLimiterOptions { now?: () => number; } +export type RateLimitScope = "create" | "unlock"; + export class RateLimiter { private readonly hits = new Map(); private readonly limit: number; @@ -84,32 +86,117 @@ export class RateLimiter { const globalForLimiters = globalThis as unknown as { __pcUnlockLimiter?: RateLimiter; __pcCreateLimiter?: RateLimiter; + __pcRedisClient?: import("redis").RedisClientType; + __pcRedisConnect?: Promise; }; -export function getUnlockRateLimiter(): RateLimiter { - if (!globalForLimiters.__pcUnlockLimiter) { - globalForLimiters.__pcUnlockLimiter = new RateLimiter({ +function scopeLimits(scope: RateLimitScope): { limit: number; windowMs: number } { + if (scope === "unlock") { + return { limit: Number.parseInt(process.env.UNLOCK_RATE_LIMIT ?? "10", 10) || 10, windowMs: Number.parseInt(process.env.UNLOCK_RATE_WINDOW_MS ?? "600000", 10) || 600_000, - }); + }; + } + return { + limit: Number.parseInt(process.env.CREATE_RATE_LIMIT ?? "60", 10) || 60, + windowMs: + Number.parseInt(process.env.CREATE_RATE_WINDOW_MS ?? "600000", 10) || + 600_000, + }; +} + +export function getUnlockRateLimiter(): RateLimiter { + if (!globalForLimiters.__pcUnlockLimiter) { + const { limit, windowMs } = scopeLimits("unlock"); + globalForLimiters.__pcUnlockLimiter = new RateLimiter({ limit, windowMs }); } return globalForLimiters.__pcUnlockLimiter; } export function getCreateRateLimiter(): RateLimiter { if (!globalForLimiters.__pcCreateLimiter) { - globalForLimiters.__pcCreateLimiter = new RateLimiter({ - limit: Number.parseInt(process.env.CREATE_RATE_LIMIT ?? "60", 10) || 60, - windowMs: - Number.parseInt(process.env.CREATE_RATE_WINDOW_MS ?? "600000", 10) || - 600_000, - }); + const { limit, windowMs } = scopeLimits("create"); + globalForLimiters.__pcCreateLimiter = new RateLimiter({ limit, windowMs }); } return globalForLimiters.__pcCreateLimiter; } +function memoryAttempt(scope: RateLimitScope, key: string): RateLimitResult { + const limiter = + scope === "unlock" ? getUnlockRateLimiter() : getCreateRateLimiter(); + return limiter.attempt(key); +} + +async function getRedisClient(): Promise { + const url = process.env.REDIS_URL?.trim(); + if (!url) return null; + + if (globalForLimiters.__pcRedisClient?.isOpen) { + return globalForLimiters.__pcRedisClient; + } + + if (!globalForLimiters.__pcRedisConnect) { + globalForLimiters.__pcRedisConnect = (async () => { + try { + const { createClient } = await import("redis"); + const client = createClient({ url }); + client.on("error", () => { + /* connection errors fall back to memory on next call */ + }); + await client.connect(); + globalForLimiters.__pcRedisClient = + client as import("redis").RedisClientType; + return globalForLimiters.__pcRedisClient; + } catch { + return null; + } + })(); + } + + return globalForLimiters.__pcRedisConnect; +} + +/** + * Fixed-window counter in Redis (multi-instance safe). + * Falls back to in-memory sliding window when REDIS_URL is unset or Redis fails. + */ +export async function checkRateLimit( + scope: RateLimitScope, + key: string, +): Promise { + const { limit, windowMs } = scopeLimits(scope); + const redisKey = `papercut:rl:${scope}:${key}`; + + try { + const client = await getRedisClient(); + if (client?.isOpen) { + const n = await client.incr(redisKey); + if (n === 1) { + await client.pExpire(redisKey, windowMs); + } + if (n > limit) { + const ttl = await client.pTTL(redisKey); + return { + allowed: false, + remaining: 0, + retryAfterSec: Math.max(1, Math.ceil((ttl > 0 ? ttl : windowMs) / 1000)), + }; + } + return { + allowed: true, + remaining: Math.max(0, limit - n), + retryAfterSec: 0, + }; + } + } catch { + // fall through + } + + return memoryAttempt(scope, key); +} + /** * Coarse client key for rate limiting without logging. * Uses X-Forwarded-For when TRUSTED_PROXY_HOPS > 0: with N trusted proxies, diff --git a/server/package.json b/server/package.json index 213efe6..1bb2f62 100644 --- a/server/package.json +++ b/server/package.json @@ -27,7 +27,8 @@ "nanoid": "^6.0.0", "next": "^15.2.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "redis": "^6.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.6",