Add implementation plan and mobile requirements to design.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,518 @@
|
|||||||
|
# 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**
|
||||||
|
|
||||||
|
```text
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 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`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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, `never`→`None`
|
||||||
|
- `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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 `/api` → `http://127.0.0.1:8080` for dev**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Global CSS**
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: Docker deploy serving SPA and API on :8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Acceptance pass (including mobile)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run backend tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 |
|
||||||
@@ -184,6 +184,13 @@ Errors: `413` too large, `429` rate limited, `403` banned IP (writes), `404` mis
|
|||||||
- Anonymous-first: landing page is immediately editable
|
- Anonymous-first: landing page is immediately editable
|
||||||
- Clear toasts for size/rate/ban failures
|
- Clear toasts for size/rate/ban failures
|
||||||
- Friendly 404 for expired/burned content
|
- Friendly 404 for expired/burned content
|
||||||
|
- **Mobile-first responsive:** usable on phones (portrait); touch-friendly controls; composer / wall stack vertically on small screens; file pick works via mobile OS picker; no hover-only actions; Element Plus grid/breakpoints (`xs`/`sm`) for layout
|
||||||
|
|
||||||
|
### Mobile acceptance
|
||||||
|
|
||||||
|
- Home / Detail / Admin usable at ~375px width without horizontal scroll of the main layout
|
||||||
|
- Create text, upload file, copy share link, open wall item on a phone browser
|
||||||
|
- Admin login and delete/ban actions work on touch devices
|
||||||
|
|
||||||
## 7. Security & limits
|
## 7. Security & limits
|
||||||
|
|
||||||
@@ -231,6 +238,7 @@ Multi-stage Dockerfile: build frontend → copy `dist` into backend image → ru
|
|||||||
5. Admin can log in with env password, delete items, ban/unban IPs.
|
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.
|
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.
|
7. `docker compose up -d` brings up a working instance on port 8080.
|
||||||
|
8. Phone browsers (~375px) can create, upload, view, copy links, and use admin actions without broken layout.
|
||||||
|
|
||||||
## 11. Implementation order (for planning)
|
## 11. Implementation order (for planning)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user