diff --git a/.env.example b/.env.example index fd181c9..3361837 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,7 @@ UNLOCK_RATE_WINDOW_MS=600000 # Create paste attempts per client within the window CREATE_RATE_LIMIT=60 CREATE_RATE_WINDOW_MS=600000 + +# Opt-in process metrics (GET /api/metrics). Off by default. +# Set to 1 / true / yes / on to enable. Counters only — no content or IPs. +# PAPERCUT_METRICS=0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a9c34..9e94b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub Actions **Publish npm** workflow — auto-publish `papercut-cli` on GitHub Release via [npm trusted publishers](https://docs.npmjs.com/trusted-publishers) (OIDC; no long-lived token) - Light/dark theme toggle (VS Code–style palettes, `localStorage` persistence) - Roadmap: **dependency currency** policy (prefer latest packages + majors plan) +- Opt-in process metrics (`PAPERCUT_METRICS=1`) — `GET /api/metrics` counters only (`pastes_created`, `unlocks_ok`, `rate_limited`); disabled by default; no content/IP logging ### Planned -See [ROADMAP.md](./ROADMAP.md) (dependency updates, opt-in metrics, scale features, etc.). +See [ROADMAP.md](./ROADMAP.md) (dependency updates, scale features, etc.). ## [1.1.0] - 2026-07-15 diff --git a/README.md b/README.md index 81fb0ea..518310f 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ See [`.env.example`](./.env.example). | `UNLOCK_RATE_WINDOW_MS` | Unlock window (ms) | `600000` | | `CREATE_RATE_LIMIT` | Creates / window | `60` | | `CREATE_RATE_WINDOW_MS` | Create window (ms) | `600000` | +| `PAPERCUT_METRICS` | Enable `GET /api/metrics` (`1`/`true`/`yes`/`on`) | off | --- @@ -161,6 +162,7 @@ See [`.env.example`](./.env.example). | `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) | +| `GET` | `/api/metrics` | Opt-in counters (`PAPERCUT_METRICS`); **404** when disabled | UI: `/paste/:id` · deep links `#L12`, `#L12-L20`. @@ -171,6 +173,7 @@ UI: `/paste/:id` · deep links `#L12`, `#L12-L20`. - No analytics SDKs in application code - Paste bodies and client IPs are **not** intentionally logged - Rate limiting may use `X-Forwarded-For` **in memory only** +- Optional metrics (`PAPERCUT_METRICS`) expose **counters only** (creates, successful unlocks, 429s) — never content or IPs - Public pastes are **capability URLs** — use `--private` for sensitive data --- diff --git a/ROADMAP.md b/ROADMAP.md index fadc029..51de569 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -63,7 +63,7 @@ Focus: production operators and contributors. | 1.1.1 | **Migrate off `next lint`** to ESLint CLI | ci, server | ✅ Done (ESLint flat config) | | 1.1.2 | **HTTP API integration tests** | api, test | ✅ Done (route-handler suite) | | 1.1.3 | **CLI publish dry-run** / npm package readiness | cli | ✅ Done (`cli/PUBLISH.md` + CI `cli-pack`) | -| 1.1.4 | **Structured app metrics (opt-in)** | server | Counters only; no content; disabled by default | +| 1.1.4 | **Structured app metrics (opt-in)** | server | ✅ Done (`PAPERCUT_METRICS` + `GET /api/metrics`) | | 1.1.5 | **Graceful DB backup note** + `sqlite3 .backup` recipe | docker, docs | ✅ Done (`docs/deploy`) | | 1.1.6 | **Dark/light toggle** (still VS Code aesthetic) | ui | ✅ Done (`localStorage` + `data-theme`) | | 1.1.7 | **Reverse proxy + HTTPS docs** (nginx, Caddy, Traefik) | docker, docs | ✅ Done (`docs/deploy`) | diff --git a/SECURITY.md b/SECURITY.md index c6601e6..7122785 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,6 +74,7 @@ PaperCut is designed with developer privacy in mind: - **No analytics** and no intentional IP or body logging of paste content in application code - **Password-protected pastes** store a password hash only; content is not returned until unlock succeeds - **Rate limits** use in-memory counters keyed by client hints (e.g. `X-Forwarded-For`); keys are not written to application logs +- **Opt-in metrics** (`PAPERCUT_METRICS`) expose process counters only (paste creates, successful unlocks, rate-limit 429s) via `GET /api/metrics`; disabled by default; never includes paste bodies, IPs, or rate-limit keys - **Self-hosting** is the primary deployment model—you control the data plane Sensitive logs or credentials should always use **private** (password-protected) pastes and short expiry when possible. A public paste ID is effectively a capability URL: anyone with the link can read a non-private paste. diff --git a/docs/deploy/README.md b/docs/deploy/README.md index 825ea8b..299e3ec 100644 --- a/docs/deploy/README.md +++ b/docs/deploy/README.md @@ -182,6 +182,7 @@ Configure Traefik entrypoints `web`/`websecure` and a Let's Encrypt certificate | `PAPERCUT_PUBLIC_URL` | `https://paste.example.com` (no trailing slash) | | `PASTE_AUTH_SECRET` | Long random secret (required in production) | | `DATABASE_PATH` | e.g. `/data/papercut.db` | +| `PAPERCUT_METRICS` | Optional: `1` to enable `GET /api/metrics` (counters only; off by default) | | Proxy headers | `X-Forwarded-For`, `X-Forwarded-Proto` | Firewall: expose **80/443** publicly; keep **3000** localhost-only or Docker-internal when using a proxy. diff --git a/server/.env.example b/server/.env.example index 45e1549..24d9bad 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,3 +6,5 @@ UNLOCK_RATE_LIMIT=10 UNLOCK_RATE_WINDOW_MS=600000 CREATE_RATE_LIMIT=60 CREATE_RATE_WINDOW_MS=600000 +# Opt-in metrics: GET /api/metrics (off by default; counters only) +# PAPERCUT_METRICS=1 diff --git a/server/app/api/api.integration.test.ts b/server/app/api/api.integration.test.ts index 510884b..069b22d 100644 --- a/server/app/api/api.integration.test.ts +++ b/server/app/api/api.integration.test.ts @@ -36,7 +36,9 @@ import { POST as createPasteRoute } from "./pastes/route"; import { GET as getPasteRoute } from "./pastes/[id]/route"; import { POST as unlockPasteRoute } from "./pastes/[id]/unlock/route"; import { GET as healthRoute } from "./health/route"; +import { GET as metricsRoute } from "./metrics/route"; import * as rateLimitMod from "@/lib/rate-limit"; +import { getMetrics } from "@/lib/metrics"; function openTempDb() { dbPath = path.join( @@ -53,6 +55,8 @@ beforeEach(() => { cookieJar.clear(); process.env.PASTE_AUTH_SECRET = "test-secret-for-api-integration"; process.env.PAPERCUT_PUBLIC_URL = "http://test.local"; + delete process.env.PAPERCUT_METRICS; + getMetrics().reset(); vi.restoreAllMocks(); }); @@ -69,6 +73,8 @@ afterEach(() => { } } dbPath = undefined; + delete process.env.PAPERCUT_METRICS; + getMetrics().reset(); }); describe("API integration", () => { @@ -229,4 +235,68 @@ describe("API integration", () => { expect(limited.status).toBe(429); expect(limited.headers.get("Retry-After")).toBeTruthy(); }); + + it("GET /api/metrics is 404 when disabled (default)", async () => { + delete process.env.PAPERCUT_METRICS; + const res = await metricsRoute(); + expect(res.status).toBe(404); + }); + + it("records counters and serves GET /api/metrics when enabled", async () => { + process.env.PAPERCUT_METRICS = "1"; + + const created = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "metrics-paste", password: "pw" }), + }), + ); + expect(created.status).toBe(201); + const { id } = await created.json(); + + const unlock = await unlockPasteRoute( + new Request(`http://test.local/api/pastes/${id}/unlock`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: "pw" }), + }), + { params: Promise.resolve({ id }) }, + ); + expect(unlock.status).toBe(200); + + const tiny = new RateLimiter({ limit: 1, windowMs: 600_000 }); + vi.spyOn(rateLimitMod, "getCreateRateLimiter").mockReturnValue(tiny); + // First create attempt succeeds under the tiny limiter and is counted; + // second hits 429. + await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "another" }), + }), + ); + const limited = await createPasteRoute( + new Request("http://test.local/api/pastes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: "blocked" }), + }), + ); + expect(limited.status).toBe(429); + + const res = await metricsRoute(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.service).toBe("papercut"); + expect(body.enabled).toBe(true); + expect(typeof body.uptimeSec).toBe("number"); + // 1 initial create + 1 under tiny limiter + expect(body.counters.pastes_created).toBe(2); + expect(body.counters.unlocks_ok).toBe(1); + expect(body.counters.rate_limited).toBe(1); + // Privacy: no content, IPs, or keys in the payload + expect(JSON.stringify(body)).not.toMatch(/metrics-paste|203\.|password|pw/); + }); }); diff --git a/server/app/api/metrics/route.ts b/server/app/api/metrics/route.ts new file mode 100644 index 0000000..a1a5123 --- /dev/null +++ b/server/app/api/metrics/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { getMetrics, isMetricsEnabled } from "@/lib/metrics"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Opt-in process counters for operators (scraping / health dashboards). + * Returns 404 when PAPERCUT_METRICS is not enabled. + * Never includes paste bodies, client IPs, or rate-limit keys. + */ +export async function GET() { + if (!isMetricsEnabled()) { + return NextResponse.json( + { error: "Not found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ); + } + + const snap = getMetrics().snapshot(); + return NextResponse.json( + { + ok: true, + service: "papercut", + ...snap, + }, + { + status: 200, + headers: { "Cache-Control": "no-store" }, + }, + ); +} diff --git a/server/app/api/pastes/[id]/unlock/route.ts b/server/app/api/pastes/[id]/unlock/route.ts index 19582b3..b469b95 100644 --- a/server/app/api/pastes/[id]/unlock/route.ts +++ b/server/app/api/pastes/[id]/unlock/route.ts @@ -5,6 +5,7 @@ import { unlockCookieOptions, } from "@/lib/auth-cookie"; import { getDb } from "@/lib/db"; +import { recordMetric } from "@/lib/metrics"; import { unlockPaste } from "@/lib/paste"; import { clientKeyFromRequest, @@ -25,6 +26,7 @@ export async function POST(request: Request, context: RouteContext) { const rateKey = `unlock:${id}:${clientKeyFromRequest(request)}`; const rate = getUnlockRateLimiter().attempt(rateKey); if (!rate.allowed) { + recordMetric("rate_limited"); return NextResponse.json( { error: "Too many unlock attempts. Try again later." }, { @@ -53,6 +55,7 @@ export async function POST(request: Request, context: RouteContext) { return NextResponse.json({ error: result.error }, { status: result.status }); } + recordMetric("unlocks_ok"); const token = createUnlockToken(id); const response = NextResponse.json({ ok: true }); response.cookies.set(authCookieName(id), token, unlockCookieOptions()); diff --git a/server/app/api/pastes/route.ts b/server/app/api/pastes/route.ts index 0ef776e..2983078 100644 --- a/server/app/api/pastes/route.ts +++ b/server/app/api/pastes/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { purgeExpiredPastes } from "@/lib/cleanup"; import { getDb } from "@/lib/db"; +import { recordMetric } from "@/lib/metrics"; import { createPaste } from "@/lib/paste"; import { clientKeyFromRequest, @@ -21,6 +22,7 @@ export async function POST(request: Request) { `create:${clientKeyFromRequest(request)}`, ); if (!rate.allowed) { + recordMetric("rate_limited"); return NextResponse.json( { error: "Too many pastes created. Try again later." }, { @@ -71,6 +73,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: result.error }, { status: result.status }); } + recordMetric("pastes_created"); const url = buildPasteUrl(result.id, request.url); return NextResponse.json( diff --git a/server/lib/metrics.test.ts b/server/lib/metrics.test.ts new file mode 100644 index 0000000..846c6ab --- /dev/null +++ b/server/lib/metrics.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + MetricsRegistry, + getMetrics, + isMetricsEnabled, + recordMetric, +} from "./metrics"; + +const KEY = "PAPERCUT_METRICS"; +let prev: string | undefined; + +beforeEach(() => { + prev = process.env[KEY]; + delete process.env[KEY]; + getMetrics().reset(); +}); + +afterEach(() => { + if (prev === undefined) delete process.env[KEY]; + else process.env[KEY] = prev; + getMetrics().reset(); +}); + +describe("isMetricsEnabled", () => { + it("is off by default", () => { + delete process.env[KEY]; + expect(isMetricsEnabled()).toBe(false); + }); + + it.each(["1", "true", "TRUE", "yes", "on", " Yes "])( + "accepts %j as enabled", + (value) => { + process.env[KEY] = value; + expect(isMetricsEnabled()).toBe(true); + }, + ); + + it.each(["0", "false", "no", "off", "", "maybe"])( + "rejects %j", + (value) => { + process.env[KEY] = value; + expect(isMetricsEnabled()).toBe(false); + }, + ); +}); + +describe("MetricsRegistry", () => { + it("starts at zero and increments named counters", () => { + const m = new MetricsRegistry({ startedAt: 0, now: () => 5_000 }); + expect(m.snapshot()).toEqual({ + enabled: true, + uptimeSec: 5, + counters: { + pastes_created: 0, + unlocks_ok: 0, + rate_limited: 0, + }, + }); + + m.inc("pastes_created"); + m.inc("unlocks_ok", 2); + m.inc("rate_limited"); + expect(m.snapshot().counters).toEqual({ + pastes_created: 1, + unlocks_ok: 2, + rate_limited: 1, + }); + }); + + it("ignores non-positive increments", () => { + const m = new MetricsRegistry(); + m.inc("pastes_created", 0); + m.inc("pastes_created", -1); + expect(m.snapshot().counters.pastes_created).toBe(0); + }); + + it("reset clears counters", () => { + const m = new MetricsRegistry(); + m.inc("pastes_created", 3); + m.reset(); + expect(m.snapshot().counters.pastes_created).toBe(0); + }); +}); + +describe("recordMetric", () => { + it("no-ops when disabled", () => { + delete process.env[KEY]; + recordMetric("pastes_created"); + expect(getMetrics().snapshot().counters.pastes_created).toBe(0); + }); + + it("increments when enabled", () => { + process.env[KEY] = "1"; + recordMetric("pastes_created"); + recordMetric("unlocks_ok"); + recordMetric("rate_limited"); + expect(getMetrics().snapshot().counters).toEqual({ + pastes_created: 1, + unlocks_ok: 1, + rate_limited: 1, + }); + }); +}); diff --git a/server/lib/metrics.ts b/server/lib/metrics.ts new file mode 100644 index 0000000..3c97f67 --- /dev/null +++ b/server/lib/metrics.ts @@ -0,0 +1,88 @@ +/** + * Opt-in in-process counters for operators. + * Disabled by default. Never stores paste bodies, IPs, or rate-limit keys. + */ + +export type MetricName = + | "pastes_created" + | "unlocks_ok" + | "rate_limited"; + +export interface MetricsSnapshot { + enabled: true; + uptimeSec: number; + counters: Record; +} + +const NAMES: readonly MetricName[] = [ + "pastes_created", + "unlocks_ok", + "rate_limited", +] as const; + +function parseTruthy(raw: string | undefined): boolean { + if (!raw) return false; + const v = raw.trim().toLowerCase(); + return v === "1" || v === "true" || v === "yes" || v === "on"; +} + +/** Whether metrics collection and the scrape endpoint are enabled. */ +export function isMetricsEnabled(): boolean { + return parseTruthy(process.env.PAPERCUT_METRICS); +} + +export class MetricsRegistry { + private readonly counters = new Map(); + private readonly startedAt: number; + private readonly now: () => number; + + constructor(options?: { now?: () => number; startedAt?: number }) { + this.now = options?.now ?? Date.now; + this.startedAt = options?.startedAt ?? this.now(); + for (const name of NAMES) { + this.counters.set(name, 0); + } + } + + inc(name: MetricName, by = 1): void { + if (!Number.isFinite(by) || by <= 0) return; + const prev = this.counters.get(name) ?? 0; + this.counters.set(name, prev + by); + } + + snapshot(): MetricsSnapshot { + const counters = {} as Record; + for (const name of NAMES) { + counters[name] = this.counters.get(name) ?? 0; + } + return { + enabled: true, + uptimeSec: Math.max(0, Math.floor((this.now() - this.startedAt) / 1000)), + counters, + }; + } + + /** Test helper */ + reset(): void { + for (const name of NAMES) { + this.counters.set(name, 0); + } + } +} + +const globalForMetrics = globalThis as unknown as { + __pcMetrics?: MetricsRegistry; +}; + +export function getMetrics(): MetricsRegistry { + if (!globalForMetrics.__pcMetrics) { + globalForMetrics.__pcMetrics = new MetricsRegistry(); + } + return globalForMetrics.__pcMetrics; +} + +/** Increment only when metrics are enabled (no-op when off). */ +export function recordMetric(name: MetricName, by = 1): void { + if (!isMetricsEnabled()) return; + getMetrics().inc(name, by); +}