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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,15 @@ 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) |

---

## HTTP API (stable for 1.x)

| 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) |
Expand Down
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
64 changes: 48 additions & 16 deletions cli/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,22 +220,58 @@ 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") {
throw new Error("fetch is not available; use Node.js 18+");
}

/** @type {Record<string, string>} */
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<string, string>} */
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;
Expand Down Expand Up @@ -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");
Expand All @@ -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,
});
Expand Down
33 changes: 33 additions & 0 deletions cli/test/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
80 changes: 80 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 29 additions & 2 deletions server/app/api/api.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 2 additions & 5 deletions server/app/api/pastes/[id]/unlock/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(
Expand Down
Loading
Loading