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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,11 @@ MAX_PASTE_SIZE=10485760

# Secret used to sign paste unlock cookies (generate a long random string)
PASTE_AUTH_SECRET=change-me-to-a-long-random-string

# Rate limits (in-memory, per process; suitable for single-node Docker)
# Unlock attempts per paste+client within the window
UNLOCK_RATE_LIMIT=10
UNLOCK_RATE_WINDOW_MS=600000
# Create paste attempts per client within the window
CREATE_RATE_LIMIT=60
CREATE_RATE_WINDOW_MS=600000
47 changes: 47 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Bug report
description: Report a defect in PaperCut
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug. **Do not** report security vulnerabilities here — use [SECURITY.md](https://github.com/ArianAr/PaperCut/blob/main/SECURITY.md).
- type: input
id: version
attributes:
label: Version
description: Git tag or package.json version (e.g. v0.2.1)
placeholder: v0.2.1
validations:
required: true
- type: dropdown
id: deploy
attributes:
label: How do you run PaperCut?
options:
- Docker / docker compose
- pnpm dev / pnpm start
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. …
2. …
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
5 changes: 5 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://github.com/ArianAr/PaperCut/security/advisories/new
about: Report security issues privately (do not open a public issue).
23 changes: 23 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Feature request
description: Suggest an enhancement for PaperCut
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What problem does this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
validations:
required: false
13 changes: 13 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Summary

<!-- What and why -->

## Test plan

- [ ] `pnpm test`
- [ ] `pnpm lint` / `pnpm typecheck` (or wait for CI)
- [ ] Manual check if UI/API behavior changed

## Notes

<!-- Migrations, env vars, breaking changes -->
28 changes: 28 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Keep GitHub-native Dependabot config in-repo (reproducible alongside UI settings).
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 10
labels:
- dependencies
groups:
production-minor:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development

- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
labels:
- dependencies
- ci
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- GitHub Actions CI (`test`, `typecheck`, `lint`, `build`, aggregate `ci`) on PRs and `main`
- Branch ruleset requiring the `ci` check before merging to `main`
- In-memory rate limits for paste create and password unlock (429 + `Retry-After`)
- Security headers (CSP, frame deny, nosniff, referrer policy) via Next config
- `GET /api/health` readiness probe; Docker healthcheck uses it
- Batch purge of expired pastes (`purgeExpiredPastes`)
- `robots.txt` disallows crawlers on `/paste/` and `/api/`
- Repo Dependabot config and issue/PR templates (CodeQL via GitHub default setup)
- CLI `--version` / `-V`

### Security

- Bump `drizzle-orm` to ≥0.45.2 (SQL identifier injection advisory)
- Bump `postcss` to ≥8.5.10 (stringify XSS advisory)

### Planned

- Unlock rate limiting for private pastes
- Multi-instance rate limiting (Redis) for horizontally scaled deploys

## [0.2.1] - 2026-07-15

Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ EXPOSE 3000
VOLUME ["/data"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(async r=>{const j=await r.json().catch(()=>({}));process.exit(r.ok&&j.ok?0:1)}).catch(()=>process.exit(1))"

CMD ["node", "server/server.js"]
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,27 @@ See [`.env.example`](./.env.example).
| `DATABASE_PATH` | SQLite file path | `./data/papercut.db` |
| `MAX_PASTE_SIZE` | Max body size (bytes) | `10485760` (10 MiB) |
| `PASTE_AUTH_SECRET` | HMAC secret for unlock cookies | required in production |
| `UNLOCK_RATE_LIMIT` | Max unlock attempts per paste+client / window | `10` |
| `UNLOCK_RATE_WINDOW_MS` | Unlock rate-limit window (ms) | `600000` (10m) |
| `CREATE_RATE_LIMIT` | Max paste creates per client / window | `60` |
| `CREATE_RATE_WINDOW_MS` | Create rate-limit window (ms) | `600000` (10m) |

## HTTP API (stable for 1.x)

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/pastes` | Create paste — JSON `{ content, expire?, password? }` → `{ id, url, expiresAt, metadata }` |
| `GET` | `/api/pastes/:id` | Fetch paste (401 + `{ locked: true }` if password required) |
| `POST` | `/api/pastes/:id/unlock` | Unlock — JSON `{ password }` sets httpOnly cookie |
| `GET` | `/api/health` | Liveness/readiness (also purges expired rows) |

Paste UI: `/paste/:id` (password gate when protected). Line deep links: `#L12`, `#L12-L20`.

## Privacy

- PaperCut application code does **not** include analytics SDKs.
- Paste bodies and client IPs are **not** intentionally logged by the app.
- Rate limiting may use `X-Forwarded-For` **in memory only** (never written to logs).
- Public pastes are **capability URLs** (anyone with the link can read them). Use `--private` for sensitive content.
- You control the host when self-hosting.

Expand Down
17 changes: 14 additions & 3 deletions cli/bin.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ const { stdin, stdout, stderr, exit, env, argv, platform } = require("node:proce

const DEFAULT_URL = "http://localhost:3000";

const CLI_VERSION = require("./package.json").version;

function printHelp() {
stdout.write(`papercut — pipe terminal output to an interactive log canvas
stdout.write(`papercut ${CLI_VERSION} — pipe terminal output to an interactive log canvas

Usage:
<command> | papercut [options]
Expand All @@ -23,6 +25,7 @@ Options:
-p, --private Password-protect the paste
--expire <time> Auto-delete after duration (e.g. 30m, 1h, 1d, 7d)
--url <base> Server base URL (default: env PAPERCUT_URL or ${DEFAULT_URL})
-V, --version Print version
-h, --help Show this help

Environment:
Expand All @@ -38,12 +41,13 @@ Examples:

/**
* @param {string[]} args
* @returns {{ help: boolean, private: boolean, expire?: string, url: string, errors: string[] }}
* @returns {{ help: boolean, version: boolean, private: boolean, expire?: string, url: string, errors: string[] }}
*/
function parseArgs(args) {
/** @type {{ help: boolean, private: boolean, expire?: string, url: string, errors: string[] }} */
/** @type {{ help: boolean, version: boolean, private: boolean, expire?: string, url: string, errors: string[] }} */
const opts = {
help: false,
version: false,
private: false,
url: (env.PAPERCUT_URL || DEFAULT_URL).replace(/\/$/, ""),
errors: [],
Expand All @@ -53,6 +57,8 @@ function parseArgs(args) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
opts.help = true;
} else if (arg === "-V" || arg === "--version") {
opts.version = true;
} else if (arg === "-p" || arg === "--private") {
opts.private = true;
} else if (arg === "--expire") {
Expand Down Expand Up @@ -258,6 +264,10 @@ async function main() {
printHelp();
exit(0);
}
if (opts.version) {
stdout.write(`${CLI_VERSION}\n`);
exit(0);
}
if (opts.errors.length) {
for (const e of opts.errors) stderr.write(`Error: ${e}\n`);
printHelp();
Expand Down Expand Up @@ -316,6 +326,7 @@ module.exports = {
printCard,
readStdin,
DEFAULT_URL,
CLI_VERSION,
};

if (require.main === module) {
Expand Down
4 changes: 3 additions & 1 deletion cli/test/parse-args.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ describe("parseArgs", () => {
assert.equal(opts.url, "http://localhost:4000");
});

it("sets help", () => {
it("sets help and version", () => {
assert.equal(parseArgs(["--help"]).help, true);
assert.equal(parseArgs(["-h"]).help, true);
assert.equal(parseArgs(["--version"]).version, true);
assert.equal(parseArgs(["-V"]).version, true);
});

it("records unknown args and missing values", () => {
Expand Down
Loading
Loading