Files
MincedPad/docs/superpowers/plans/2026-07-14-mincedpad-implementation.md
T
2026-07-14 12:24:09 +08:00

15 KiB

MincedPad Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a self-hosted anonymous paste/file share app (MincedPad) with public wall, TTL/burn, admin moderation, Docker deploy, and mobile-usable UI.

Architecture: FastAPI + SQLite + local uploads in one process; Vue3/Element Plus SPA built into the image and served by FastAPI; in-memory per-IP rate limit; background cleaner for expiry.

Tech Stack: Python 3.12, FastAPI, SQLAlchemy, SQLite, python-jose/passlib or PyJWT, Vue 3, Vite, Element Plus, TypeScript, Docker multi-stage.

Spec: docs/superpowers/specs/2026-07-14-mincedpad-design.md


File map (create)

Path Responsibility
backend/app/config.py Env settings
backend/app/db.py Engine/session
backend/app/models.py ORM models
backend/app/schemas.py Pydantic schemas
backend/app/security.py JWT admin auth
backend/app/services/storage.py File save/delete
backend/app/services/rate_limit.py 2/sec per IP
backend/app/services/bans.py Ban checks
backend/app/services/items.py Item CRUD/burn/expire
backend/app/services/cleaner.py Periodic cleanup
backend/app/api/public.py Public routes
backend/app/api/admin.py Admin routes
backend/app/main.py App factory + static SPA
backend/requirements.txt Deps
backend/tests/test_items.py API tests
frontend/ Vue3 SPA (mobile-first)
Dockerfile Multi-stage build
docker-compose.yml Deploy
README.md Usage

Task 1: Backend skeleton (config, DB, models)

Files:

  • Create: backend/requirements.txt

  • Create: backend/app/__init__.py

  • Create: backend/app/config.py

  • Create: backend/app/db.py

  • Create: backend/app/models.py

  • Create: backend/app/main.py (minimal health)

  • Step 1: Add dependencies

fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
python-multipart==0.0.20
pyjwt==2.10.1
httpx==0.28.1
pytest==8.3.4
  • Step 2: Implement config
# backend/app/config.py
from pathlib import Path
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    data_dir: Path = Path("/data")
    admin_password: str = "changeme"
    secret_key: str = "dev-secret-change-me"
    max_upload_mb: int = 200
    rate_limit_per_second: int = 2
    jwt_expire_hours: int = 12
    host: str = "0.0.0.0"
    port: int = 8080

    @property
    def db_url(self) -> str:
        return f"sqlite:///{self.data_dir / 'mincedpad.db'}"

    @property
    def uploads_dir(self) -> Path:
        return self.data_dir / "uploads"

settings = Settings()

Also add pydantic-settings==2.7.0 to requirements.

  • Step 3: DB + models
# backend/app/models.py (essentials)
class Item(Base):
    __tablename__ = "items"
    id: Mapped[int] = mapped_column(primary_key=True)
    slug: Mapped[str] = mapped_column(String(32), unique=True, index=True)
    kind: Mapped[str] = mapped_column(String(16))  # text|file
    title: Mapped[str] = mapped_column(String(255), default="")
    body: Mapped[str | None] = mapped_column(Text, nullable=True)
    file_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
    file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
    mime: Mapped[str | None] = mapped_column(String(128), nullable=True)
    size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
    is_public: Mapped[bool] = mapped_column(Boolean, default=True)
    burn_after_read: Mapped[bool] = mapped_column(Boolean, default=False)
    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
    created_ip: Mapped[str] = mapped_column(String(64), default="")
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    view_count: Mapped[int] = mapped_column(Integer, default=0)
    burned: Mapped[bool] = mapped_column(Boolean, default=False)

class BannedIP(Base):
    __tablename__ = "banned_ips"
    ip: Mapped[str] = mapped_column(String(64), primary_key=True)
    reason: Mapped[str] = mapped_column(String(255), default="")
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
  • Step 4: Minimal app with /api/health
@app.get("/api/health")
def health():
    return {"ok": True, "name": "MincedPad"}

On startup: settings.data_dir.mkdir, uploads_dir.mkdir, Base.metadata.create_all.

  • Step 5: Commit
git add backend
git commit -m "feat: scaffold FastAPI app, config, and SQLite models"

Task 2: Item service + public create/get/wall APIs

Files:

  • Create: backend/app/schemas.py

  • Create: backend/app/services/items.py

  • Create: backend/app/api/public.py

  • Create: backend/tests/conftest.py

  • Create: backend/tests/test_items.py

  • Modify: backend/app/main.py — include router

  • Step 1: Write failing tests

def test_create_text_default_public_and_24h(client):
    r = client.post("/api/items", json={"body": "hello **md**"})
    assert r.status_code == 200
    data = r.json()
    assert data["slug"]
    assert data["is_public"] is True
    assert data["expires_at"] is not None

def test_link_only_not_on_wall(client):
    r = client.post("/api/items", json={"body": "secret", "is_public": False})
    slug = r.json()["slug"]
    wall = client.get("/api/items/wall").json()["items"]
    assert all(i["slug"] != slug for i in wall)
    assert client.get(f"/api/items/{slug}").status_code == 200
  • Step 2: Run tests — expect FAIL
cd backend && PYTHONPATH=. pytest tests/test_items.py -v
  • Step 3: Implement schemas + items service

  • Slug: secrets.token_urlsafe(8) trimmed to URL-safe short id

  • TTL map: 1h→+1h, 24h→+24h, 7d→+7d, neverNone

  • get_item: 404 if missing/expired/burned; if burn_after_read and not yet burned, return once then set burned=True and delete file if any

  • Wall: is_public==True, not burned, not expired, order by created_at desc

  • Step 4: Wire POST /api/items, GET /api/items/wall, GET /api/items/{slug}

Client IP from request.client.host (and honor X-Forwarded-For first hop when present for Docker proxy later).

  • Step 5: Tests PASS, then commit
git commit -m "feat: public text items, wall, and detail API"

Task 3: File upload, allowlist, download

Files:

  • Create: backend/app/services/storage.py

  • Modify: backend/app/api/public.py

  • Modify: backend/tests/test_items.py

  • Step 1: Failing tests for upload + size reject

def test_upload_small_file(client, tmp_path):
    files = {"file": ("hi.txt", b"abc", "text/plain")}
    data = {"is_public": "true", "ttl": "24h"}
    r = client.post("/api/items/upload", files=files, data=data)
    assert r.status_code == 200
    slug = r.json()["slug"]
    f = client.get(f"/api/items/{slug}/file")
    assert f.status_code == 200
    assert f.content == b"abc"

def test_reject_oversize(client, monkeypatch):
    monkeypatch.setenv("MAX_UPLOAD_MB", "1")
    # re-load settings or override app dependency — use 1MB+ body
    ...

For oversize test, override settings.max_upload_mb = 1 in fixture if env reload is awkward.

  • Step 2: Implement storage.save_upload(file) -> (rel_path, size, mime, safe_name)

Allowlist extensions from spec. Reject others with 400.

  • Step 3: POST /api/items/upload + GET /api/items/{slug}/file

Use FileResponse with correct media_type; images Content-Disposition: inline.

  • Step 4: Tests PASS, commit
git commit -m "feat: file upload, allowlist, and download/preview"

Task 4: Rate limit (2/s) + IP bans on writes

Files:

  • Create: backend/app/services/rate_limit.py

  • Create: backend/app/services/bans.py

  • Modify: backend/app/api/public.py

  • Modify: backend/tests/test_items.py

  • Step 1: Failing tests

def test_rate_limit_third_request_in_same_second(client):
    # three quick creates → last is 429
    ...

def test_banned_ip_cannot_create(client, db):
    db.add(BannedIP(ip="testclient"))
    ...
    assert client.post("/api/items", json={"body": "x"}).status_code == 403

Note: Starlette TestClient IP is often testclient — assert against that or inject dependency.

  • Step 2: Sliding window: deque of timestamps per IP; allow if < rate_limit_per_second in last 1.0s

  • Step 3: Ban check before create/upload

  • Step 4: Tests PASS, commit

git commit -m "feat: per-IP rate limit and write bans"

Task 5: Cleaner + burn-after-read verification

Files:

  • Create: backend/app/services/cleaner.py

  • Modify: backend/app/main.py — start background task

  • Modify: backend/tests/test_items.py

  • Step 1: Test burn

def test_burn_after_read(client):
    r = client.post("/api/items", json={"body": "once", "burn_after_read": True, "ttl": "never"})
    slug = r.json()["slug"]
    assert client.get(f"/api/items/{slug}").status_code == 200
    assert client.get(f"/api/items/{slug}").status_code == 404
  • Step 2: Test cleaner removes expired

Create item with expires_at in the past via service/DB, run cleaner.run_once(db), assert gone.

  • Step 3: Implement lifespan task asyncio.sleep(60) loop calling run_once

  • Step 4: Commit

git commit -m "feat: burn-after-read and expiry cleaner"

Task 6: Admin auth + moderation APIs

Files:

  • Create: backend/app/security.py

  • Create: backend/app/api/admin.py

  • Create: backend/tests/test_admin.py

  • Modify: backend/app/main.py

  • Step 1: Tests for login, delete item, ban/unban

def test_admin_login_and_delete(client):
    token = client.post("/api/admin/login", json={"password": "changeme"}).json()["token"]
    headers = {"Authorization": f"Bearer {token}"}
    slug = client.post("/api/items", json={"body": "bye"}).json()["slug"]
    items = client.get("/api/admin/items", headers=headers).json()["items"]
    item_id = next(i["id"] for i in items if i["slug"] == slug)
    assert client.delete(f"/api/admin/items/{item_id}", headers=headers).status_code == 200
    assert client.get(f"/api/items/{slug}").status_code == 404
  • Step 2: JWT create/verify with SECRET_KEY; password compare to ADMIN_PASSWORD

  • Step 3: Implement admin routes from spec

  • Step 4: Commit

git commit -m "feat: admin login, delete items, and IP bans API"

Task 7: Frontend scaffold (Vue3 + Element Plus, mobile-first)

Files:

  • Create: frontend/package.json, vite.config.ts, index.html, src/main.ts, src/App.vue, src/router/index.ts, src/api/client.ts, src/styles.css

  • Step 1: Scaffold with Vite vue-ts; add element-plus, vue-router, markdown-it

cd frontend && npm create vite@latest . -- --template vue-ts
npm i element-plus vue-router markdown-it
npm i -D @types/markdown-it
  • Step 2: Vite proxy /apihttp://127.0.0.1:8080 for dev

  • Step 3: Global CSS

html, body, #app { margin: 0; min-height: 100%; }
.page { max-width: 960px; margin: 0 auto; padding: 12px 16px 32px; }
@media (max-width: 768px) {
  .page { padding: 8px 12px 24px; }
  .desktop-only { display: none !important; }
}
  • Step 4: Router routes /, /p/:slug, /admin

  • Step 5: Viewport meta in index.html:
    width=device-width, initial-scale=1, viewport-fit=cover

  • Step 6: Commit

git commit -m "feat: scaffold Vue3 Element Plus frontend with mobile base"

Task 8: Home page — composer + wall (responsive)

Files:

  • Create: frontend/src/views/HomeView.vue

  • Create: frontend/src/components/Composer.vue

  • Create: frontend/src/components/WallList.vue

  • Step 1: Layout with el-row / el-col :xs="24" :md="12" — stack on phone

  • Step 2: Composer controls

  • Markdown el-input type textarea

  • TTL el-select default 24h

  • Switches: burn, link-only

  • Buttons full-width on xs

  • Upload: el-upload drag + accept + mobile falls back to file picker

  • Step 3: On success show share URL with copy button (navigator.clipboard)

  • Step 4: Wall list cards; poll GET /api/items/wall every 15s; tap navigates to /p/:slug

  • Step 5: Manual check at 375px width (browser device mode)

  • Step 6: Commit

git commit -m "feat: home composer and public wall UI"

Task 9: Detail + Admin views

Files:

  • Create: frontend/src/views/ItemView.vue

  • Create: frontend/src/views/AdminView.vue

  • Step 1: ItemView — fetch slug; render markdown via markdown-it; images <img> from /api/items/:slug/file; other types download link

  • Step 2: If API returns burn hint / first open, show el-alert once

  • Step 3: AdminView — password form; store JWT in sessionStorage; tables for items/bans; touch-friendly row actions

  • Step 4: Commit

git commit -m "feat: item detail and admin moderation UI"

Task 10: Serve SPA from FastAPI + Docker Compose

Files:

  • Modify: backend/app/main.py — mount frontend/dist, SPA fallback

  • Create: Dockerfile

  • Create: docker-compose.yml

  • Create: .dockerignore

  • Create: .gitignore

  • Create: README.md

  • Step 1: Static mount

dist = Path(__file__).resolve().parents[2] / "frontend" / "dist"
if dist.exists():
    app.mount("/assets", StaticFiles(directory=dist / "assets"), name="assets")
    @app.get("/{full_path:path}")
    async def spa(full_path: str):
        if full_path.startswith("api/"):
            raise HTTPException(404)
        index = dist / "index.html"
        return FileResponse(index)

Order: API routers registered before SPA catch-all.

  • Step 2: Multi-stage Dockerfile

Stage1: node:20 build frontend
Stage2: python:3.12-slim install backend, copy dist → /app/frontend/dist, CMD uvicorn on 8080

  • Step 3: docker-compose with env + ./data:/data port 8080

  • Step 4: Build and smoke

docker compose up -d --build
curl -s http://127.0.0.1:8080/api/health
curl -s -X POST http://127.0.0.1:8080/api/items -H 'content-type: application/json' -d '{"body":"hi"}'
  • Step 5: README — deploy, env vars, mobile note, admin URL

  • Step 6: Commit

git commit -m "feat: Docker deploy serving SPA and API on :8080"

Task 11: Acceptance pass (including mobile)

  • Step 1: Run backend tests
cd backend && PYTHONPATH=. pytest -v

Expected: all PASS

  • Step 2: Manual checklist against spec §10 + mobile

  • Create text + file on desktop and phone width

  • Wall vs link-only

  • TTL / burn

  • Admin delete + ban

  • 429 when hammering create

  • Upload over limit rejected

  • Step 3: Final commit if any polish

git commit -m "chore: polish for mobile acceptance"

Spec coverage checklist

Spec requirement Task
Anonymous create/share 2, 3, 8
Public wall + link-only 2, 8
Markdown + files 3, 8, 9
TTL default 24h + burn 2, 5
Admin password JWT 6, 9
Rate 2/s, 200MB 3, 4
Docker Compose 10
Mobile usable 7, 8, 9, 11