Compare commits
2
Commits
651bc0cb86
..
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8a953e4bf | ||
|
|
cb1b16ae41 |
-315
@@ -1,315 +0,0 @@
|
|||||||
# Palette API
|
|
||||||
|
|
||||||
Palette's REST API lives under `/api` on the same port that serves the web UI. All request and response bodies are JSON unless noted otherwise. This document describes the API as implemented in `internal/api`.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
### Base URL
|
|
||||||
|
|
||||||
The server listens on the address set by `PALETTE_ADDR` (default `:8080`). In production palette runs behind a reverse proxy, so API URLs are relative to the site root, for example:
|
|
||||||
|
|
||||||
```
|
|
||||||
https://paste.example.com/api/pastes
|
|
||||||
```
|
|
||||||
|
|
||||||
The examples below use `http://localhost:8080` as a placeholder.
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
There is no user account system. Access control works through three mechanisms:
|
|
||||||
|
|
||||||
- Viewer cookie. Every request gets an anonymous, random viewer id in an HttpOnly `vwr` cookie. This cookie identifies the browser that created a paste or can, and authorizes browser-scoped actions such as `GET /api/mine` and creator-only deletion.
|
|
||||||
- Deletion token. Creating a paste returns a one-time `deletion_token`. Sending it in the `Authorization` header (Bearer or Token prefix, or the bare value) authorizes deletion of that paste. The token is deliberately not accepted as a URL query parameter, because query strings leak into access logs and browser history.
|
|
||||||
- Admin key. The admin settings endpoints require an install-time key sent in the `X-Admin-Key` header. The key comes from `PALETTE_ADMIN_KEY` or is generated and persisted server-side at first start.
|
|
||||||
|
|
||||||
Password-protected pastes and cans are unlocked with the `X-Paste-Password` request header (or the browser unlock flow on the web pages). Password checks are rate limited per IP and per paste.
|
|
||||||
|
|
||||||
### Rate limiting
|
|
||||||
|
|
||||||
Rate limits are token buckets keyed by client IP (the rightmost `X-Forwarded-For` entry, then `X-Real-Ip`, then the remote address). Current limits:
|
|
||||||
|
|
||||||
| Scope | Limit | Refill |
|
|
||||||
|---|---|---|
|
|
||||||
| Paste create | 5 burst (admin tunable) | 60 per minute (admin tunable) |
|
|
||||||
| Language guess | 5 burst | 1 per second |
|
|
||||||
| Password unlock | 5 burst, per IP and paste | 5 per minute |
|
|
||||||
| Admin key check | 5 burst, per IP | 5 per minute |
|
|
||||||
|
|
||||||
Exceeding a limit returns `429` with a `Retry-After` header and the error code `rate_limited`. Create and guess responses include `X-RateLimit-Limit` and `X-RateLimit-Burst` headers.
|
|
||||||
|
|
||||||
### Size limits
|
|
||||||
|
|
||||||
- Paste content: `PALETTE_MAX_TEXT` (default 5 MB), admin tunable. Larger content is rejected with `413`.
|
|
||||||
- Attached files: 25 MB per file, one file per paste.
|
|
||||||
- Can items: `PALETTE_MAX_ITEM` (default 25 MB) per item.
|
|
||||||
- The request body itself is capped server side; oversized bodies fail with `413` before handlers run.
|
|
||||||
|
|
||||||
### Error format
|
|
||||||
|
|
||||||
Errors are JSON with a human-readable `error` message and, for create/validation failures, a machine-readable `code`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"error": "slug already taken", "code": "slug_taken"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Known codes: `content_empty`, `content_too_large`, `slug_invalid`, `slug_taken`, `slug_reserved`, `expiry_invalid`, `invalid_visibility`, `one_file_only`, `file_too_large`, `rate_limited`, `invalid_request`. Unknown codes should be treated as a generic failure.
|
|
||||||
|
|
||||||
## Endpoint reference
|
|
||||||
|
|
||||||
### Pastes
|
|
||||||
|
|
||||||
#### POST /api/pastes
|
|
||||||
|
|
||||||
Create a paste. Rate limited.
|
|
||||||
|
|
||||||
Two body formats:
|
|
||||||
|
|
||||||
1. `application/json` with these fields:
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `content` | string | required; non-whitespace; up to the content cap |
|
|
||||||
| `title` | string | optional, trimmed, truncated to 200 chars |
|
|
||||||
| `language` | string | optional, max 40 chars, `^[a-zA-Z0-9+#-]{1,40}$` |
|
|
||||||
| `custom_slug` | string | optional custom URL slug |
|
|
||||||
| `password` | string | optional; hash stored server side |
|
|
||||||
| `expires_in` | string | Go duration (`90m`, `6h`, `168h`); 1 minute to 1 year |
|
|
||||||
| `burn_after_read` | bool | convenience for a single-read budget |
|
|
||||||
| `burn_after_reads` | int | number of reads the paste survives (min 1) |
|
|
||||||
| `visibility` | string | `public` or `unlisted`; default `public` |
|
|
||||||
| `public` | bool | alias for `visibility`; takes precedence when both are sent |
|
|
||||||
|
|
||||||
2. `multipart/form-data` with the same fields as form values plus a `file` part. A paste is either text or one file; if both are sent the file wins and the text is ignored. The MIME type is sniffed from the file content, never from client headers.
|
|
||||||
|
|
||||||
If the admin has set a default expiry, pastes created without `expires_in` inherit it.
|
|
||||||
|
|
||||||
Response `201`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "ab12cd34",
|
|
||||||
"deletion_token": "one-time token",
|
|
||||||
"url": "/ab12cd34",
|
|
||||||
"raw_url": "/raw/ab12cd34",
|
|
||||||
"api_url": "/api/pastes/ab12cd34",
|
|
||||||
"expires_at": 1735689600,
|
|
||||||
"created_at": 1735603200,
|
|
||||||
"rate_limit": {"create_per_sec": 1, "burst": 5}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
With a multipart file, an extra `attachment` object is returned: `id`, `filename`, `mime`, `size`, `sha256`, `url` (`/f/{aid}/{filename}`).
|
|
||||||
|
|
||||||
The deletion token is also set as a short-lived `tok_{id}` cookie for the creating browser.
|
|
||||||
|
|
||||||
Errors: `400` invalid JSON or validation (codes `content_empty`, `slug_invalid`, `slug_taken`, `slug_reserved`, `expiry_invalid`, `one_file_only`), `413` (`content_too_large`, `file_too_large`), `429` rate limited.
|
|
||||||
|
|
||||||
#### GET /api/pastes/{id}
|
|
||||||
|
|
||||||
Fetch a paste's JSON representation. For password-protected pastes send `X-Paste-Password`; wrong or missing passwords return `401` and count against the unlock rate limit.
|
|
||||||
|
|
||||||
Response `200`: `id`, `content`, `content_type`, `language`, `title`, `created_at`, `view_count`, `visibility`, and `reads_remaining` (number or `null` when no read budget is set).
|
|
||||||
|
|
||||||
Errors: `404` not found, expired, or burned out; `401` password required; `429` too many unlock attempts.
|
|
||||||
|
|
||||||
#### DELETE /api/pastes/{id}
|
|
||||||
|
|
||||||
Soft delete a paste. Requires either the deletion token in the `Authorization` header or the creating browser's `vwr` cookie.
|
|
||||||
|
|
||||||
Response `200`: `{"status": "soft-deleted"}`.
|
|
||||||
|
|
||||||
Errors: `403` deletion token required, `404` not found.
|
|
||||||
|
|
||||||
#### DELETE /api/pastes/{id}/redeem
|
|
||||||
|
|
||||||
Hard delete immediately using the one-time deletion token in the `Authorization` header. The paste row is removed entirely instead of waiting for the sweeper.
|
|
||||||
|
|
||||||
Response `200`: `{"status": "deleted"}`.
|
|
||||||
|
|
||||||
Errors: `400` token required, `403` invalid token, `404` not found.
|
|
||||||
|
|
||||||
#### GET /api/mine
|
|
||||||
|
|
||||||
List pastes (and cans) created from this browser, via the `vwr` cookie. Query parameters: `limit` (default 50, max 100), `offset`.
|
|
||||||
|
|
||||||
Response `200`: `{"total": n, "limit": l, "offset": o, "items": [...]}` with each item carrying `id`, `title`, `language`, `created_at`, `view_count`, `size`, `custom_slug`, `visibility`, `is_can`.
|
|
||||||
|
|
||||||
#### GET /api/public
|
|
||||||
|
|
||||||
List public pastes. Query parameters: `limit` (default 25, max 100), `offset`. Same shape as `/api/mine`; items include `is_can` and `custom_slug`.
|
|
||||||
|
|
||||||
### Files and raw content
|
|
||||||
|
|
||||||
#### GET /raw/{id}
|
|
||||||
|
|
||||||
Paste content served verbatim. Content types outside a safe fixed set (plain text, markdown, JSON, PDF, common images, octet-stream) are served as `text/plain` with `nosniff`. Raw reads count against a burn-after-read budget, with the same per-viewer dedupe window as page views (15 minutes by default, admin tunable). Password-protected pastes are not available raw (`401`).
|
|
||||||
|
|
||||||
Errors: `401` password required, `404` not found, expired, or burned.
|
|
||||||
|
|
||||||
#### GET /f/{aid}/{filename}
|
|
||||||
|
|
||||||
Download a file attachment. The filename path segment is decorative; lookups key on the attachment id. Files are served with the server-sniffed MIME type, `nosniff`, and a safe `Content-Disposition`; only images and PDFs render inline, everything else downloads. Attachments inherit the paste lifecycle: expired or burned pastes make their attachments disappear.
|
|
||||||
|
|
||||||
Errors: `404` attachment or paste not found.
|
|
||||||
|
|
||||||
### Language detection
|
|
||||||
|
|
||||||
#### POST /api/guess-language
|
|
||||||
|
|
||||||
Body: `{"content": "package main"}`. Rate limited like create.
|
|
||||||
|
|
||||||
Response `200`: `{"language": "go"}`. The result is a best-effort guess and may be `""`.
|
|
||||||
|
|
||||||
Errors: `400` invalid JSON body, `429` rate limited.
|
|
||||||
|
|
||||||
### Cans
|
|
||||||
|
|
||||||
A can bundles multiple text items and files into one shareable page at `/can/{id}`. Cans appear in `/api/mine` and `/api/public` with `is_can: true`.
|
|
||||||
|
|
||||||
#### POST /api/pastes/can
|
|
||||||
|
|
||||||
Create a can. Multipart form only. Fields:
|
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `title` | string | optional |
|
|
||||||
| `description` | string | optional |
|
|
||||||
| `visibility` | string | `public` or `unlisted`; default `public` |
|
|
||||||
| `expires_in` | string | Go duration, 1 minute to 1 year |
|
|
||||||
| `password` | string | optional |
|
|
||||||
| `custom_slug` | string | optional; becomes the can id |
|
|
||||||
| `json_items` | string | JSON array of `{"title": "...", "content": "...", "language": "..."}` text items |
|
|
||||||
| files | parts | one or more uploaded file parts |
|
|
||||||
|
|
||||||
A can needs at least one item (file or `json_items`); otherwise the can is deleted and `400` (`content_empty`) is returned.
|
|
||||||
|
|
||||||
Response `201`: `{"id": "...", "url": "/can/{id}", "items": n}`.
|
|
||||||
|
|
||||||
Errors: `400` (`invalid_visibility`, `expiry_invalid`, `invalid json_items`, `content_empty`), `409` slug errors (codes `slug_taken`, `slug_invalid`, `slug_reserved`), `413` item over the size cap.
|
|
||||||
|
|
||||||
#### GET /api/cans/{id}
|
|
||||||
|
|
||||||
Can metadata with an item list. Password-protected cans require `X-Paste-Password`.
|
|
||||||
|
|
||||||
Response `200`: `id`, `title`, `visibility`, `created_at`, and `items`, where each item has `id`, `title`, `content_type`, `size`, and `url` (the item's fetch URL).
|
|
||||||
|
|
||||||
Errors: `404` not found or expired, `401` password required.
|
|
||||||
|
|
||||||
#### GET /api/cans/{id}/items/{item}
|
|
||||||
|
|
||||||
Fetch one can item's content. Items inherit the can's password: send `X-Paste-Password`, or the browser's `pw_{can}` unlock cookie. Served with the same safe content-type guard as `/raw`.
|
|
||||||
|
|
||||||
Errors: `404` item not found or not a can item, `401` password required.
|
|
||||||
|
|
||||||
#### DELETE /api/cans/{id}
|
|
||||||
|
|
||||||
Soft delete a can. Creator browser only: the `vwr` cookie must match the can's viewer. Cans carry no deletion token.
|
|
||||||
|
|
||||||
Response `200`: `{"status": "soft-deleted"}`.
|
|
||||||
|
|
||||||
Errors: `403` deletion not authorized, `404` not found.
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
|
|
||||||
#### GET /admin/api/settings and POST /admin/api/settings
|
|
||||||
|
|
||||||
Read or update runtime settings. Requires the `X-Admin-Key` header; wrong keys return `401` and all admin key checks are rate limited to 5 per minute per IP. The admin key is never accepted as a query parameter.
|
|
||||||
|
|
||||||
Settings object (POST accepts the full object and persists it):
|
|
||||||
|
|
||||||
| Field | Default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `rate_limit_burst` | 5 | paste-create token bucket burst |
|
|
||||||
| `rate_limit_per_minute` | 60 | paste-create refill rate |
|
|
||||||
| `max_content_bytes` | 5242880 | paste content cap |
|
|
||||||
| `default_expiry` | `""` | expiry applied to pastes created without one |
|
|
||||||
| `custom_slug_reservation_days` | 30 | how long a released slug stays reserved |
|
|
||||||
| `burn_viewer_window_minutes` | 15 | per-viewer dedupe window for burn reads |
|
|
||||||
|
|
||||||
Errors: `400` invalid JSON or validation (all numeric settings must be positive, `default_expiry` must be a duration between 1 minute and 1 year or empty), `401` unauthorized, `429` rate limited.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
Create a text paste:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/pastes \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"content": "print(hello)",
|
|
||||||
"title": "my snippet",
|
|
||||||
"language": "python",
|
|
||||||
"expires_in": "168h",
|
|
||||||
"visibility": "public"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "ab12cd34",
|
|
||||||
"deletion_token": "TOKEN",
|
|
||||||
"url": "/ab12cd34",
|
|
||||||
"raw_url": "/raw/ab12cd34",
|
|
||||||
"api_url": "/api/pastes/ab12cd34",
|
|
||||||
"expires_at": 1735689600,
|
|
||||||
"created_at": 1735603200,
|
|
||||||
"rate_limit": {"create_per_sec": 1, "burst": 5}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Save the `deletion_token`; it is shown once and authorizes deletion.
|
|
||||||
|
|
||||||
Create a file paste:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/pastes \
|
|
||||||
-F "title=quarterly report" \
|
|
||||||
-F "file=@report.pdf"
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetch a paste:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/api/pastes/ab12cd34
|
|
||||||
|
|
||||||
# password-protected paste (header only; query parameters are not accepted):
|
|
||||||
curl -H "X-Paste-Password: secret" http://localhost:8080/api/pastes/ab12cd34
|
|
||||||
```
|
|
||||||
|
|
||||||
Fetch raw content:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/raw/ab12cd34
|
|
||||||
```
|
|
||||||
|
|
||||||
Soft delete (paste becomes unavailable but is recoverable during the grace period):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34
|
|
||||||
```
|
|
||||||
|
|
||||||
Hard delete immediately (row removed):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE -H "Authorization: Bearer TOKEN" http://localhost:8080/api/pastes/ab12cd34/redeem
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a can:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/pastes/can \
|
|
||||||
-F "title=My bundle" \
|
|
||||||
-F "expires_in=48h" \
|
|
||||||
-F "custom_slug=my-bundle" \
|
|
||||||
-F 'json_items=[{"title":"notes.txt","content":"some notes"}]' \
|
|
||||||
-F "files=@screenshot.png"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deletion and expiry behavior
|
|
||||||
|
|
||||||
- Expired pastes are soft-deleted by a background sweeper that runs every minute.
|
|
||||||
- Soft-deleted pastes are hard-deleted after a 7-day grace period.
|
|
||||||
- The deletion token allows immediate hard delete via the redeem endpoint.
|
|
||||||
- Burn-after-read pastes are soft-deleted once the read budget is exhausted. A read is counted once per unique viewer within the dedupe window (15 minutes by default); the same viewer returning inside the window does not consume another read.
|
|
||||||
Reference in new issue
Block a user