commit 198a321c51e587db5dcce85bcc02dbbe77d97b37 Author: 肉末 Date: Tue Jul 14 12:21:10 2026 +0800 Add MincedPad design spec for self-hosted share notepad. Co-authored-by: Cursor diff --git a/docs/superpowers/specs/2026-07-14-mincedpad-design.md b/docs/superpowers/specs/2026-07-14-mincedpad-design.md new file mode 100644 index 0000000..ad1b4a2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-mincedpad-design.md @@ -0,0 +1,242 @@ +# MincedPad Design Spec + +**Date:** 2026-07-14 +**Project:** MincedPad — 自托管公共数据共享与记事本平台 +**Status:** Draft for review + +## 1. Overview + +MincedPad is a lightweight, self-hosted web tool for anonymously pasting text and uploading files, then sharing via a public wall and/or a direct link. No registration is required for visitors. An admin password gate provides moderation (delete content, ban IPs). + +### Goals + +- Zero-login create / share / view +- Public wall by default, optional link-only +- Markdown text + common file upload/preview/download +- TTL (default 24h) and burn-after-read +- Docker Compose one-shot deploy, low resource use +- Rate limit, upload size cap, admin moderation + +### Non-goals (v1) + +- Multi-admin RBAC / audit export +- Multi-node / object-storage backends +- Real-time WebSocket wall updates +- End-to-end encryption (content is readable by the server) + +## 2. Decisions (confirmed) + +| Topic | Choice | +| --- | --- | +| Name | MincedPad | +| Stack | FastAPI + Vue3 + Element Plus SPA | +| Architecture | Monorepo; frontend built into backend static hosting; one container | +| Storage | SQLite metadata + local `data/uploads/` files | +| Visibility | Default public on wall; optional “link only” | +| Expiry | Default 24h; options 1h / 24h / 7d / never; optional burn-after-read | +| Admin | Single password via env → JWT | +| Rate limit | 2 create/upload requests per IP per second | +| Max file | 200 MB (env configurable) | + +## 3. Architecture + +``` +Browser (Vue3 + Element Plus) + │ + ▼ + FastAPI (single process) + ├── /api/* JSON API + ├── /admin UI routes (SPA) + ├── /* SPA static (dist/) + ├── SQLite data/mincedpad.db + └── Files data/uploads/ + └── Cleaner thread expire + burn cleanup +``` + +### Repository layout + +``` +MincedPad/ +├── backend/ +│ ├── app/ +│ │ ├── main.py +│ │ ├── config.py +│ │ ├── db.py +│ │ ├── models.py +│ │ ├── schemas.py +│ │ ├── api/ +│ │ │ ├── public.py +│ │ │ └── admin.py +│ │ ├── services/ +│ │ │ ├── items.py +│ │ │ ├── storage.py +│ │ │ ├── rate_limit.py +│ │ │ ├── bans.py +│ │ │ └── cleaner.py +│ │ └── security.py +│ ├── requirements.txt +│ └── Dockerfile +├── frontend/ +│ ├── package.json +│ ├── vite.config.ts +│ └── src/ +│ ├── views/ # Home, ItemDetail, Admin +│ ├── api/ +│ └── components/ +├── data/ # runtime volume (gitignored) +├── docker-compose.yml +├── README.md +└── docs/superpowers/specs/ +``` + +### Runtime + +- Default listen `:8080` +- Compose mounts `./data:/data` +- Env: `ADMIN_PASSWORD`, `SECRET_KEY`, `MAX_UPLOAD_MB=200`, `RATE_LIMIT_PER_SECOND=2` + +## 4. Data model + +### `items` + +| Column | Type | Notes | +| --- | --- | --- | +| id | INTEGER PK | | +| slug | TEXT UNIQUE | short public id (e.g. nanoid) | +| kind | TEXT | `text` \| `file` | +| title | TEXT | optional; default from first line or filename | +| body | TEXT NULL | markdown/plain for text items | +| file_name | TEXT NULL | original name | +| file_path | TEXT NULL | relative path under uploads | +| mime | TEXT NULL | | +| size_bytes | INTEGER NULL | | +| is_public | BOOLEAN | true = on wall; false = link only | +| burn_after_read | BOOLEAN | | +| expires_at | DATETIME NULL | null = never | +| created_ip | TEXT | | +| created_at | DATETIME | | +| view_count | INTEGER | | +| burned | BOOLEAN | true after burn consumed | + +### `banned_ips` + +| Column | Type | Notes | +| --- | --- | --- | +| ip | TEXT PK | | +| reason | TEXT | | +| created_at | DATETIME | | + +Admin auth uses signed JWT (HS256) with `SECRET_KEY`; no separate sessions table in v1. + +## 5. API + +### Public + +| Method | Path | Description | +| --- | --- | --- | +| POST | `/api/items` | Create text item (JSON) | +| POST | `/api/items/upload` | Create file item (multipart) | +| GET | `/api/items/wall` | Paginated public wall (`page`, `page_size`) | +| GET | `/api/items/{slug}` | Get item metadata + text body; may burn | +| GET | `/api/items/{slug}/file` | Download/inline file | + +Create body (text) fields: `body`, `title?`, `is_public` (default true), `burn_after_read` (default false), `ttl` (`1h`\|`24h`\|`7d`\|`never`, default `24h`). + +Upload form fields: file + same options. + +Errors: `413` too large, `429` rate limited, `403` banned IP (writes), `404` missing/expired/burned. + +### Admin (Bearer JWT) + +| Method | Path | Description | +| --- | --- | --- | +| POST | `/api/admin/login` | `{ password }` → `{ token }` | +| GET | `/api/admin/items` | All items, newest first | +| DELETE | `/api/admin/items/{id}` | Force delete + file | +| GET | `/api/admin/bans` | List bans | +| POST | `/api/admin/bans` | `{ ip, reason? }` | +| DELETE | `/api/admin/bans/{ip}` | Unban | + +## 6. Frontend + +### Pages + +1. **Home `/`** + - Composer: Markdown textarea (Element Plus) + drag-and-drop upload zone + - Options: TTL select (default 24h), burn-after-read switch, link-only switch + - Actions: “发布文本” / 上传后自动生成结果 + - Result: copyable share URL + - Wall: recent public items (poll every ~15s or on focus) + +2. **Detail `/p/:slug`** + - Text: Markdown render + - Image MIME: inline preview + - Other files: download button (+ optional content-disposition inline for pdf when safe) + - Burn warning before first view if applicable + +3. **Admin `/admin`** + - Password login + - Table of items with delete + - Ban IP form + ban list with unban + +### UX constraints + +- Anonymous-first: landing page is immediately editable +- Clear toasts for size/rate/ban failures +- Friendly 404 for expired/burned content + +## 7. Security & limits + +- **Upload cap:** default 200 MB via `MAX_UPLOAD_MB` +- **Rate limit:** 2 create/upload ops per IP per second (in-memory sliding window; acceptable for single-node) +- **Ban list:** banned IPs cannot create/upload; wall and read still allowed unless later tightened +- **Filename:** store as random UUID + safe extension; never trust client path +- **Extension allowlist (v1):** common images (`png/jpg/jpeg/gif/webp/svg`), text/docs (`txt/md/pdf/doc/docx/xls/xlsx/ppt/pptx/csv`), archives (`zip/7z/tar/gz/rar`), plus `json` +- **Admin:** `ADMIN_PASSWORD` required at startup in production; JWT expiry 12h + +## 8. Background cleanup + +A lightweight loop (async task or thread) every 60s: + +1. Delete items where `expires_at < now` +2. Delete burned items already marked and past grace (immediate delete on burn read is OK) +3. Remove orphan files if item row gone + +## 9. Docker / deploy + +```yaml +# conceptual +services: + mincedpad: + build: . + ports: ["8080:8080"] + environment: + ADMIN_PASSWORD: "change-me" + SECRET_KEY: "change-me" + MAX_UPLOAD_MB: "200" + RATE_LIMIT_PER_SECOND: "2" + volumes: + - ./data:/data + restart: unless-stopped +``` + +Multi-stage Dockerfile: build frontend → copy `dist` into backend image → run uvicorn. + +## 10. Acceptance criteria (v1) + +1. Unauthenticated users can create text and upload files and receive a share URL. +2. New items are on the public wall unless “link only” is set. +3. Default TTL is 24h; 1h / 7d / never and burn-after-read work. +4. Markdown renders; images preview; other allowed types download. +5. Admin can log in with env password, delete items, ban/unban IPs. +6. Uploads over 200 MB rejected; more than 2 create/upload requests per second per IP get 429. +7. `docker compose up -d` brings up a working instance on port 8080. + +## 11. Implementation order (for planning) + +1. Backend skeleton: config, DB models, create/get/wall, file storage +2. Rate limit + bans + cleaner +3. Admin auth + moderation APIs +4. Vue pages: Home, Detail, Admin +5. Docker multi-stage + compose + README +6. Smoke tests / manual acceptance checklist