Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand All @@ -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`.

Expand All @@ -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

---
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 70 additions & 0 deletions server/app/api/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
});

Expand All @@ -69,6 +73,8 @@ afterEach(() => {
}
}
dbPath = undefined;
delete process.env.PAPERCUT_METRICS;
getMetrics().reset();
});

describe("API integration", () => {
Expand Down Expand Up @@ -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/);
});
});
32 changes: 32 additions & 0 deletions server/app/api/metrics/route.ts
Original file line number Diff line number Diff line change
@@ -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" },
},
);
}
3 changes: 3 additions & 0 deletions server/app/api/pastes/[id]/unlock/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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." },
{
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions server/app/api/pastes/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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." },
{
Expand Down Expand Up @@ -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(
Expand Down
103 changes: 103 additions & 0 deletions server/lib/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading
Loading