Compare commits

..

10 Commits

Author SHA1 Message Date
肉末 7c2489ea5c feat: Docker deploy serving SPA and API on :8080
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:03:51 +08:00
肉末 600c536367 feat: item detail and admin moderation UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:02:11 +08:00
肉末 445a940f7d feat: home composer and public wall UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:01:44 +08:00
肉末 81d6b377c7 feat: scaffold Vue3 Element Plus frontend with mobile base
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:01:21 +08:00
肉末 c92bcb0074 feat: admin login, delete items, and IP bans API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:54:20 +08:00
肉末 4d697692e0 feat: burn-after-read and expiry cleaner
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:53:58 +08:00
肉末 175a4ad0d1 feat: per-IP rate limit and write bans
Apply sliding-window 2/s limits and BannedIP checks on write routes.
Also force SVG attachment disposition and claim burn-after-read in DB
before streaming, deleting the file via BackgroundTask after the response.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:52:45 +08:00
肉末 f2a1f3a1d6 fix: burn file items after download, not metadata fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:48:42 +08:00
肉末 eda7010cae feat: file upload, allowlist, and download/preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:47:58 +08:00
肉末 0e59936e34 feat: public text items, wall, and detail API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 12:43:53 +08:00
44 changed files with 4572 additions and 3 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.worktrees
.venv
venv
data
**/__pycache__
**/*.pyc
.pytest_cache
frontend/node_modules
frontend/dist
frontend/.vscode
*.db
.env
.DS_Store
**/*.md
!README.md
docs
backend/tests
+24
View File
@@ -0,0 +1,24 @@
# Stage 1 — build Vue SPA
FROM node:20 AS frontend-build
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2 — FastAPI + built SPA
FROM python:3.12-slim
WORKDIR /app
ENV DATA_DIR=/data \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/app ./app
COPY --from=frontend-build /frontend/dist ./frontend/dist
EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+60
View File
@@ -0,0 +1,60 @@
# MincedPad
Self-hosted anonymous paste / file share: public wall, TTL, burn-after-read, and a password-gated admin panel. One Docker container serves the Vue SPA and FastAPI API on port **8080**.
## Deploy (Docker Compose)
```bash
docker compose up -d --build
```
Open http://127.0.0.1:8080/
Admin: http://127.0.0.1:8080/admin
Data (SQLite + uploads) persists in `./data` on the host (`DATA_DIR=/data` in the container).
## Environment
| Variable | Default | Meaning |
| --- | --- | --- |
| `ADMIN_PASSWORD` | `changeme` | Admin login password |
| `SECRET_KEY` | (dev default) | JWT signing secret — change in production |
| `MAX_UPLOAD_MB` | `200` | Max upload size in megabytes |
| `RATE_LIMIT_PER_SECOND` | `2` | Create/upload rate limit per IP |
| `DATA_DIR` | `/data` | SQLite + uploads directory |
Set these under `environment:` in `docker-compose.yml` (or via your orchestrator).
## Mobile
The UI is mobile-first (Element Plus responsive grid, full-width controls under 768px). Use a real phone or browser device mode for smoke checks after deploy.
## Local development
**API**
```bash
cd backend
python -m venv ../.venv && source ../.venv/bin/activate
pip install -r requirements.txt
DATA_DIR=../data PYTHONPATH=. uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload
```
**Frontend** (proxies `/api` to `:8080`)
```bash
cd frontend
npm install
npm run dev
```
Production-style (API serves `frontend/dist`):
```bash
cd frontend && npm run build
cd ../backend && DATA_DIR=../data PYTHONPATH=. uvicorn app.main:app --port 8080
```
## License
Use as you like for self-hosting.
View File
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import config
from app.db import get_db
from app.models import BannedIP, Item
from app.security import create_access_token, require_admin
from app.services import items as items_service
router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginBody(BaseModel):
password: str
class BanCreate(BaseModel):
ip: str = Field(min_length=1)
reason: str = ""
@router.post("/login")
def login(body: LoginBody):
if body.password != config.settings.admin_password:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"token": create_access_token()}
@router.get("/items")
def list_items(
_admin: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
rows = list(db.scalars(select(Item).order_by(Item.created_at.desc())).all())
items = []
for item in rows:
data = items_service.item_to_dict(item)
data["id"] = item.id
data["created_ip"] = item.created_ip
data["burned"] = item.burned
items.append(data)
return {"items": items}
@router.delete("/items/{item_id}")
def delete_item(
item_id: int,
_admin: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
items_service.delete_item_file(item)
db.delete(item)
db.commit()
return {"ok": True}
@router.get("/bans")
def list_bans(
_admin: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
rows = list(db.scalars(select(BannedIP).order_by(BannedIP.created_at.desc())).all())
return {
"bans": [
{"ip": b.ip, "reason": b.reason, "created_at": b.created_at} for b in rows
]
}
@router.post("/bans")
def create_ban(
body: BanCreate,
_admin: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
existing = db.get(BannedIP, body.ip)
if existing is None:
db.add(BannedIP(ip=body.ip, reason=body.reason or ""))
else:
existing.reason = body.reason or existing.reason
db.commit()
return {"ok": True}
@router.delete("/bans/{ip}")
def delete_ban(
ip: str,
_admin: dict = Depends(require_admin),
db: Session = Depends(get_db),
):
ban = db.get(BannedIP, ip)
if ban is None:
raise HTTPException(status_code=404, detail="Not found")
db.delete(ban)
db.commit()
return {"ok": True}
+147
View File
@@ -0,0 +1,147 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from starlette.background import BackgroundTask
from app.db import get_db
from app.schemas import ItemCreate, ItemOut, TTLOption, WallResponse
from app.services import bans as bans_service
from app.services import items as items_service
from app.services import rate_limit as rate_limit_service
from app.services import storage as storage_service
router = APIRouter(prefix="/api/items", tags=["items"])
_INLINE_IMAGE_PREFIXES = ("image/",)
def _client_ip(request: Request) -> str:
"""IP used for rate limits, bans, and created_ip.
Prefer ``request.client.host`` (TestClient yields ``testclient``; direct
connections use the peer address). ``X-Forwarded-For`` is only a fallback
when client is missing — trusting XFF without a trusted reverse proxy
allows clients to spoof their IP for ban/rate-limit evasion.
"""
if request.client and request.client.host:
return request.client.host
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return ""
def _enforce_write_guards(request: Request, db: Session) -> str:
ip = _client_ip(request)
if bans_service.is_banned(db, ip):
raise HTTPException(status_code=403, detail="Forbidden")
if not rate_limit_service.allow(ip):
raise HTTPException(status_code=429, detail="Too Many Requests")
return ip
def _content_disposition_type(media_type: str, filename: str) -> str:
# SVG can carry script; never inline even when mime is image/svg+xml.
if media_type == "image/svg+xml" or filename.lower().endswith(".svg"):
return "attachment"
if media_type.startswith(_INLINE_IMAGE_PREFIXES):
return "inline"
return "attachment"
@router.post("", response_model=ItemOut)
def create_item(
payload: ItemCreate,
request: Request,
db: Session = Depends(get_db),
):
ip = _enforce_write_guards(request, db)
item = items_service.create_text_item(db, payload, ip)
return items_service.item_to_dict(item)
@router.post("/upload", response_model=ItemOut)
async def upload_item(
request: Request,
db: Session = Depends(get_db),
file: UploadFile = File(...),
is_public: bool = Form(True),
burn_after_read: bool = Form(False),
ttl: TTLOption = Form("24h"),
title: str | None = Form(None),
):
ip = _enforce_write_guards(request, db)
rel_path, size, mime, safe_name = await storage_service.save_upload(file)
item = items_service.create_file_item(
db,
file_name=safe_name,
file_path=rel_path,
mime=mime,
size_bytes=size,
is_public=is_public,
burn_after_read=burn_after_read,
ttl=ttl,
created_ip=ip,
title=title,
)
return items_service.item_to_dict(item)
@router.get("/wall", response_model=WallResponse)
def wall(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
rows, total = items_service.list_wall(db, page=page, page_size=page_size)
return WallResponse(
items=[ItemOut.model_validate(items_service.item_to_dict(i)) for i in rows],
page=page,
page_size=page_size,
total=total,
)
def _delete_file_path(path: str) -> None:
try:
Path(path).unlink(missing_ok=True)
except OSError:
pass
@router.get("/{slug}/file")
def download_file(slug: str, db: Session = Depends(get_db)):
item = items_service.get_file_item(db, slug)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
path = items_service.resolve_file_path(item)
if path is None or not path.is_file():
raise HTTPException(status_code=404, detail="Not found")
media_type = item.mime or "application/octet-stream"
filename = item.file_name or path.name
disposition = _content_disposition_type(media_type, filename)
background = None
if item.burn_after_read:
# Claim burn before streaming so concurrent fetches cannot re-read.
items_service.claim_burn(db, item)
background = BackgroundTask(_delete_file_path, str(path))
return FileResponse(
path,
media_type=media_type,
filename=filename,
content_disposition_type=disposition,
background=background,
)
@router.get("/{slug}", response_model=ItemOut)
def get_item(slug: str, db: Session = Depends(get_db)):
item = items_service.get_item(db, slug)
if item is None:
raise HTTPException(status_code=404, detail="Not found")
return items_service.item_to_dict(item)
+61 -2
View File
@@ -1,10 +1,43 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.admin import router as admin_router
from app.api.public import router as public_router
from app.config import settings from app.config import settings
from app.db import Base, engine from app.db import Base, SessionLocal, engine
from app import models # noqa: F401 — register models with Base.metadata from app import models # noqa: F401 — register models with Base.metadata
from app.services import cleaner as cleaner_service
def _resolve_frontend_dist() -> Path | None:
"""Locate frontend/dist in Docker (/app/frontend/dist) or monorepo checkout."""
here = Path(__file__).resolve()
candidates = (
here.parents[2] / "frontend" / "dist", # .../backend/app/main.py → repo root
here.parents[1] / "frontend" / "dist", # /app/app/main.py → /app/frontend/dist
Path("/app/frontend/dist"),
)
for path in candidates:
if (path / "index.html").is_file():
return path
return None
async def _cleaner_loop() -> None:
while True:
db = SessionLocal()
try:
cleaner_service.run_once(db)
except Exception:
pass
finally:
db.close()
await asyncio.sleep(60)
@asynccontextmanager @asynccontextmanager
@@ -12,12 +45,38 @@ async def lifespan(_app: FastAPI):
settings.data_dir.mkdir(parents=True, exist_ok=True) settings.data_dir.mkdir(parents=True, exist_ok=True)
settings.uploads_dir.mkdir(parents=True, exist_ok=True) settings.uploads_dir.mkdir(parents=True, exist_ok=True)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
task = asyncio.create_task(_cleaner_loop())
try:
yield yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="MincedPad", lifespan=lifespan) app = FastAPI(title="MincedPad", lifespan=lifespan)
app.include_router(public_router)
app.include_router(admin_router)
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"ok": True, "name": "MincedPad"} return {"ok": True, "name": "MincedPad"}
_dist = _resolve_frontend_dist()
if _dist is not None:
assets_dir = _dist / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.api_route("/{full_path:path}", methods=["GET", "HEAD"])
async def spa(full_path: str):
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
file_path = _dist / full_path
if full_path and file_path.is_file():
return FileResponse(file_path)
return FileResponse(_dist / "index.html")
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
TTLOption = Literal["1h", "24h", "7d", "never"]
class ItemCreate(BaseModel):
body: str = Field(min_length=1)
title: str | None = None
is_public: bool = True
burn_after_read: bool = False
ttl: TTLOption = "24h"
class ItemOut(BaseModel):
slug: str
kind: str
title: str
body: str | None = None
file_name: str | None = None
mime: str | None = None
size_bytes: int | None = None
is_public: bool
burn_after_read: bool
expires_at: datetime | None
created_at: datetime
view_count: int
model_config = {"from_attributes": True}
class WallResponse(BaseModel):
items: list[ItemOut]
page: int
page_size: int
total: int
+39
View File
@@ -0,0 +1,39 @@
"""Admin JWT helpers (HS256)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app import config
_bearer = HTTPBearer(auto_error=False)
def create_access_token(*, expires_hours: int | None = None) -> str:
hours = expires_hours if expires_hours is not None else config.settings.jwt_expire_hours
now = datetime.now(timezone.utc)
payload = {
"sub": "admin",
"iat": now,
"exp": now + timedelta(hours=hours),
}
return jwt.encode(payload, config.settings.secret_key, algorithm="HS256")
def verify_token(token: str) -> dict:
try:
return jwt.decode(token, config.settings.secret_key, algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=401, detail="Unauthorized") from exc
def require_admin(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict:
if creds is None or creds.scheme.lower() != "bearer" or not creds.credentials:
raise HTTPException(status_code=401, detail="Unauthorized")
return verify_token(creds.credentials)
View File
+14
View File
@@ -0,0 +1,14 @@
"""IP ban checks for write endpoints."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import BannedIP
def is_banned(db: Session, ip: str) -> bool:
if not ip:
return False
return db.scalar(select(BannedIP).where(BannedIP.ip == ip)) is not None
+35
View File
@@ -0,0 +1,35 @@
"""Periodic cleanup of expired and burned items."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.models import Item
from app.services import items as items_service
def run_once(db: Session, *, now: datetime | None = None) -> int:
"""Delete expired rows (+ files) and burned rows (leftover files).
Returns the number of item rows removed.
"""
now = now or datetime.utcnow()
rows = list(
db.scalars(
select(Item).where(
or_(
Item.burned.is_(True),
(Item.expires_at.is_not(None) & (Item.expires_at <= now)),
)
)
).all()
)
for item in rows:
items_service.delete_item_file(item)
db.delete(item)
if rows:
db.commit()
return len(rows)
+198
View File
@@ -0,0 +1,198 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import config
from app.models import Item
from app.schemas import ItemCreate, TTLOption
TTL_DELTAS: dict[TTLOption, timedelta | None] = {
"1h": timedelta(hours=1),
"24h": timedelta(days=1),
"7d": timedelta(days=7),
"never": None,
}
def _slug() -> str:
return secrets.token_urlsafe(8)
def _expires_at(ttl: TTLOption, now: datetime | None = None) -> datetime | None:
delta = TTL_DELTAS[ttl]
if delta is None:
return None
return (now or datetime.utcnow()) + delta
def _title_from_body(body: str) -> str:
first = body.split("\n", 1)[0].strip()
return first[:255]
def _is_unavailable(item: Item, now: datetime | None = None) -> bool:
if item.burned:
return True
now = now or datetime.utcnow()
if item.expires_at is not None and item.expires_at <= now:
return True
return False
def item_to_dict(item: Item, *, include_body: bool = True) -> dict:
data = {
"slug": item.slug,
"kind": item.kind,
"title": item.title,
"file_name": item.file_name,
"mime": item.mime,
"size_bytes": item.size_bytes,
"is_public": item.is_public,
"burn_after_read": item.burn_after_read,
"expires_at": item.expires_at,
"created_at": item.created_at,
"view_count": item.view_count,
}
if include_body:
data["body"] = item.body
return data
def create_text_item(db: Session, payload: ItemCreate, created_ip: str) -> Item:
now = datetime.utcnow()
title = payload.title if payload.title is not None else _title_from_body(payload.body)
item = Item(
slug=_slug(),
kind="text",
title=title[:255],
body=payload.body,
is_public=payload.is_public,
burn_after_read=payload.burn_after_read,
expires_at=_expires_at(payload.ttl, now),
created_ip=created_ip,
created_at=now,
)
db.add(item)
db.commit()
db.refresh(item)
return item
def create_file_item(
db: Session,
*,
file_name: str,
file_path: str,
mime: str,
size_bytes: int,
is_public: bool,
burn_after_read: bool,
ttl: TTLOption,
created_ip: str,
title: str | None = None,
) -> Item:
now = datetime.utcnow()
item = Item(
slug=_slug(),
kind="file",
title=(title or file_name)[:255],
body=None,
file_name=file_name,
file_path=file_path,
mime=mime,
size_bytes=size_bytes,
is_public=is_public,
burn_after_read=burn_after_read,
expires_at=_expires_at(ttl, now),
created_ip=created_ip,
created_at=now,
)
db.add(item)
db.commit()
db.refresh(item)
return item
def resolve_file_path(item: Item) -> Path | None:
if not item.file_path:
return None
path = Path(item.file_path)
if not path.is_absolute():
path = config.settings.data_dir / path
return path
def get_file_item(db: Session, slug: str) -> Item | None:
item = db.scalar(select(Item).where(Item.slug == slug))
if item is None or item.kind != "file" or _is_unavailable(item):
return None
return item
def claim_burn(db: Session, item: Item) -> None:
"""Mark burn consumed in DB before streaming bytes."""
item.burned = True
db.commit()
def delete_item_file(item: Item) -> None:
"""Remove file bytes from disk (safe after response has finished streaming)."""
path = resolve_file_path(item)
if path is not None:
try:
path.unlink(missing_ok=True)
except OSError:
pass
def burn_file_item(db: Session, item: Item) -> None:
"""Mark a file item burned and remove its bytes from disk."""
claim_burn(db, item)
delete_item_file(item)
def get_item(db: Session, slug: str) -> Item | None:
item = db.scalar(select(Item).where(Item.slug == slug))
if item is None or _is_unavailable(item):
return None
item.view_count += 1
# File items burn on download, not metadata fetch.
if item.burn_after_read and item.kind != "file":
item.burned = True
db.commit()
db.refresh(item)
return item
def list_wall(
db: Session,
*,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Item], int]:
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
now = datetime.utcnow()
filters = [
Item.is_public.is_(True),
Item.burned.is_(False),
(Item.expires_at.is_(None) | (Item.expires_at > now)),
]
total = db.scalar(select(func.count()).select_from(Item).where(*filters)) or 0
items = list(
db.scalars(
select(Item)
.where(*filters)
.order_by(Item.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
).all()
)
return items, total
+40
View File
@@ -0,0 +1,40 @@
"""In-memory sliding-window rate limit (per IP)."""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
from app import config
_lock = threading.Lock()
_hits: dict[str, deque[float]] = defaultdict(deque)
def reset() -> None:
"""Clear all tracked hits (tests)."""
with _lock:
_hits.clear()
def allow(ip: str, *, now: float | None = None) -> bool:
"""Return True if ``ip`` may proceed; record the hit when allowed.
Allows when the number of hits in the last 1.0s is strictly less than
``settings.rate_limit_per_second``.
"""
if not ip:
ip = ""
limit = config.settings.rate_limit_per_second
ts = time.monotonic() if now is None else now
window_start = ts - 1.0
with _lock:
q = _hits[ip]
while q and q[0] <= window_start:
q.popleft()
if len(q) >= limit:
return False
q.append(ts)
return True
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import re
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile
from app import config
ALLOWED_EXTENSIONS = frozenset(
{
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"txt",
"md",
"pdf",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"csv",
"zip",
"7z",
"tar",
"gz",
"rar",
"json",
}
)
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_original_name(filename: str | None) -> str:
name = Path(filename or "file").name
name = _SAFE_NAME_RE.sub("_", name).strip("._") or "file"
return name[:255]
def _extension(filename: str) -> str:
return Path(filename).suffix.lstrip(".").lower()
async def save_upload(file: UploadFile) -> tuple[str, int, str, str]:
original = _safe_original_name(file.filename)
ext = _extension(original)
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail="File type not allowed")
max_bytes = config.settings.max_upload_mb * 1024 * 1024
config.settings.uploads_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid.uuid4().hex}.{ext}"
dest = config.settings.uploads_dir / stored_name
size = 0
try:
with dest.open("wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > max_bytes:
raise HTTPException(status_code=413, detail="File too large")
out.write(chunk)
except HTTPException:
dest.unlink(missing_ok=True)
raise
except Exception:
dest.unlink(missing_ok=True)
raise
mime = file.content_type or "application/octet-stream"
rel_path = f"uploads/{stored_name}"
return rel_path, size, mime, original
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture()
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
from app.config import Settings
import app.config as config_module
import app.db as db_module
import app.main as main_module
from app.services import rate_limit as rate_limit_module
settings = Settings(_env_file=None, DATA_DIR=tmp_path)
config_module.settings = settings
# Multi-create tests are not about rate limits; the dedicated test sets 2/s.
monkeypatch.setattr(config_module.settings, "rate_limit_per_second", 10_000)
rate_limit_module.reset()
engine = create_engine(
settings.db_url,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db_module.engine = engine
db_module.SessionLocal = SessionLocal
main_module.engine = engine
with TestClient(main_module.app) as test_client:
yield test_client
+32
View File
@@ -0,0 +1,32 @@
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
def test_admin_login_rejects_bad_password(client):
r = client.post("/api/admin/login", json={"password": "wrong"})
assert r.status_code == 401
def test_admin_requires_bearer(client):
assert client.get("/api/admin/items").status_code == 401
assert client.get("/api/admin/items", headers={"Authorization": "Bearer nope"}).status_code == 401
def test_admin_ban_and_unban(client):
token = client.post("/api/admin/login", json={"password": "changeme"}).json()["token"]
headers = {"Authorization": f"Bearer {token}"}
r = client.post("/api/admin/bans", headers=headers, json={"ip": "1.2.3.4", "reason": "spam"})
assert r.status_code == 200
bans = client.get("/api/admin/bans", headers=headers).json()["bans"]
assert any(b["ip"] == "1.2.3.4" and b.get("reason") == "spam" for b in bans)
assert client.delete("/api/admin/bans/1.2.3.4", headers=headers).status_code == 200
bans_after = client.get("/api/admin/bans", headers=headers).json()["bans"]
assert all(b["ip"] != "1.2.3.4" for b in bans_after)
+273
View File
@@ -0,0 +1,273 @@
from datetime import datetime, timedelta
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
assert data["kind"] == "text"
assert data["title"] == "hello **md**"
assert data["body"] == "hello **md**"
assert data["burn_after_read"] is False
assert data["view_count"] == 0
assert data["created_at"]
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
def test_ttl_options(client):
never = client.post("/api/items", json={"body": "n", "ttl": "never"}).json()
assert never["expires_at"] is None
one_h = client.post("/api/items", json={"body": "h", "ttl": "1h"}).json()
expires = datetime.fromisoformat(one_h["expires_at"])
delta = expires - datetime.fromisoformat(one_h["created_at"])
assert timedelta(minutes=50) < delta < timedelta(hours=2)
week = client.post("/api/items", json={"body": "w", "ttl": "7d"}).json()
expires_w = datetime.fromisoformat(week["expires_at"])
delta_w = expires_w - datetime.fromisoformat(week["created_at"])
assert timedelta(days=6) < delta_w < timedelta(days=8)
def test_wall_pagination_shape(client):
for i in range(3):
client.post("/api/items", json={"body": f"item {i}", "is_public": True})
r = client.get("/api/items/wall", params={"page": 1, "page_size": 2})
assert r.status_code == 200
data = r.json()
assert "items" in data
assert data["page"] == 1
assert data["page_size"] == 2
assert data["total"] >= 3
assert len(data["items"]) == 2
# newest first
titles = [i["title"] for i in data["items"]]
assert titles[0] == "item 2"
def test_get_missing_404(client):
assert client.get("/api/items/does-not-exist").status_code == 404
def test_title_from_first_line(client):
r = client.post("/api/items", json={"body": "First line\nSecond line"})
assert r.json()["title"] == "First line"
def test_explicit_title(client):
r = client.post("/api/items", json={"body": "body text", "title": "Custom"})
assert r.json()["title"] == "Custom"
def test_upload_small_file(client):
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):
import app.config as config_module
monkeypatch.setattr(config_module.settings, "max_upload_mb", 1)
big = b"x" * (1 * 1024 * 1024 + 1)
files = {"file": ("big.txt", big, "text/plain")}
r = client.post("/api/items/upload", files=files, data={"is_public": "true", "ttl": "24h"})
assert r.status_code == 413
def test_reject_disallowed_extension(client):
files = {"file": ("malware.exe", b"mz", "application/octet-stream")}
r = client.post(
"/api/items/upload",
files=files,
data={"is_public": "true", "ttl": "24h"},
)
assert r.status_code == 400
def test_file_burn_after_download_not_metadata(client):
files = {"file": ("secret.txt", b"top-secret", "text/plain")}
data = {"is_public": "true", "ttl": "24h", "burn_after_read": "true"}
r = client.post("/api/items/upload", files=files, data=data)
assert r.status_code == 200
slug = r.json()["slug"]
meta = client.get(f"/api/items/{slug}")
assert meta.status_code == 200
assert meta.json()["file_name"] == "secret.txt"
assert meta.json()["burn_after_read"] is True
first = client.get(f"/api/items/{slug}/file")
assert first.status_code == 200
assert first.content == b"top-secret"
assert client.get(f"/api/items/{slug}/file").status_code == 404
assert client.get(f"/api/items/{slug}").status_code == 404
def test_svg_forced_attachment_disposition(client):
files = {"file": ("xss.svg", b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml")}
r = client.post(
"/api/items/upload",
files=files,
data={"is_public": "true", "ttl": "24h"},
)
assert r.status_code == 200
slug = r.json()["slug"]
f = client.get(f"/api/items/{slug}/file")
assert f.status_code == 200
cd = f.headers.get("content-disposition", "")
assert "attachment" in cd
assert "inline" not in cd.split(";")[0]
def test_burn_claims_before_stream(client, monkeypatch):
"""burned=True is set before streaming, not only in the post-response BackgroundTask."""
import app.db as db_module
import app.api.public as public_api
from app.models import Item
from sqlalchemy import select
# Background only deletes bytes; claiming is done in the request handler.
monkeypatch.setattr(public_api, "_delete_file_path", lambda *_a, **_k: None)
files = {"file": ("once.txt", b"payload", "text/plain")}
r = client.post(
"/api/items/upload",
files=files,
data={"is_public": "true", "ttl": "never", "burn_after_read": "true"},
)
assert r.status_code == 200
slug = r.json()["slug"]
first = client.get(f"/api/items/{slug}/file")
assert first.status_code == 200
assert first.content == b"payload"
db = db_module.SessionLocal()
try:
item = db.scalar(select(Item).where(Item.slug == slug))
assert item is not None
assert item.burned is True
finally:
db.close()
assert client.get(f"/api/items/{slug}/file").status_code == 404
def test_rate_limit_third_request_in_same_second(client, monkeypatch):
import app.config as config_module
from app.services import rate_limit as rate_limit_module
monkeypatch.setattr(config_module.settings, "rate_limit_per_second", 2)
rate_limit_module.reset()
r1 = client.post("/api/items", json={"body": "one"})
r2 = client.post("/api/items", json={"body": "two"})
r3 = client.post("/api/items", json={"body": "three"})
assert r1.status_code == 200
assert r2.status_code == 200
assert r3.status_code == 429
def test_banned_ip_cannot_create(client):
import app.db as db_module
from app.models import BannedIP
db = db_module.SessionLocal()
try:
db.add(BannedIP(ip="testclient", reason="test"))
db.commit()
finally:
db.close()
r = client.post("/api/items", json={"body": "x"})
assert r.status_code == 403
def test_burn_after_read(client):
r = client.post(
"/api/items",
json={"body": "once", "burn_after_read": True, "ttl": "never"},
)
assert r.status_code == 200
slug = r.json()["slug"]
assert client.get(f"/api/items/{slug}").status_code == 200
assert client.get(f"/api/items/{slug}").status_code == 404
def test_cleaner_removes_expired(client):
import app.db as db_module
from app.models import Item
from app.services import cleaner
from sqlalchemy import select
r = client.post("/api/items", json={"body": "old", "ttl": "never"})
assert r.status_code == 200
slug = r.json()["slug"]
db = db_module.SessionLocal()
try:
item = db.scalar(select(Item).where(Item.slug == slug))
assert item is not None
item.expires_at = datetime.utcnow() - timedelta(hours=1)
db.commit()
n = cleaner.run_once(db)
assert n >= 1
assert db.scalar(select(Item).where(Item.slug == slug)) is None
finally:
db.close()
assert client.get(f"/api/items/{slug}").status_code == 404
def test_cleaner_removes_burned_leftover_files(client, monkeypatch):
import app.db as db_module
import app.api.public as public_api
from app.models import Item
from app.services import cleaner
from app.services import items as items_service
from sqlalchemy import select
monkeypatch.setattr(public_api, "_delete_file_path", lambda *_a, **_k: None)
files = {"file": ("gone.txt", b"bytes", "text/plain")}
r = client.post(
"/api/items/upload",
files=files,
data={"is_public": "true", "ttl": "never", "burn_after_read": "true"},
)
assert r.status_code == 200
slug = r.json()["slug"]
assert client.get(f"/api/items/{slug}/file").status_code == 200
db = db_module.SessionLocal()
try:
item = db.scalar(select(Item).where(Item.slug == slug))
assert item is not None
path = items_service.resolve_file_path(item)
assert path is not None and path.is_file()
assert item.burned is True
cleaner.run_once(db)
assert not path.exists()
assert db.scalar(select(Item).where(Item.slug == slug)) is None
finally:
db.close()
+13
View File
@@ -0,0 +1,13 @@
services:
mincedpad:
build: .
ports: ["8080:8080"]
environment:
ADMIN_PASSWORD: "changeme"
SECRET_KEY: "change-me-in-production"
MAX_UPLOAD_MB: "200"
RATE_LIMIT_PER_SECOND: "2"
DATA_DIR: "/data"
volumes:
- ./data:/data
restart: unless-stopped
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>MincedPad</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2079
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"element-plus": "^2.14.3",
"markdown-it": "^14.3.0",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<div class="page">
<header class="app-header">
<RouterLink class="app-brand" to="/">MincedPad</RouterLink>
<nav class="app-nav">
<RouterLink to="/">首页</RouterLink>
<RouterLink to="/admin">管理</RouterLink>
</nav>
</header>
<RouterView />
</div>
</template>
<style scoped>
a {
color: #409eff;
text-decoration: none;
}
a.router-link-active:not(.app-brand) {
font-weight: 600;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
export type TTLOption = '1h' | '24h' | '7d' | 'never'
export interface Item {
slug: string
kind: string
title: string
body?: string | null
file_name?: string | null
mime?: string | null
size_bytes?: number | null
is_public: boolean
burn_after_read: boolean
expires_at: string | null
created_at: string
view_count: number
id?: number
created_ip?: string
burned?: boolean
}
export interface WallResponse {
items: Item[]
page: number
page_size: number
total: number
}
export interface Ban {
ip: string
reason: string
created_at: string
}
async function parseError(res: Response): Promise<string> {
try {
const data = await res.json()
if (typeof data?.detail === 'string') return data.detail
return JSON.stringify(data?.detail ?? data)
} catch {
return res.statusText || `HTTP ${res.status}`
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, init)
if (!res.ok) {
throw new Error(await parseError(res))
}
if (res.status === 204) {
return undefined as T
}
return (await res.json()) as T
}
export function createItem(payload: {
body: string
title?: string
is_public: boolean
burn_after_read: boolean
ttl: TTLOption
}): Promise<Item> {
return request<Item>('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
export function uploadItem(form: FormData): Promise<Item> {
return request<Item>('/api/items/upload', {
method: 'POST',
body: form,
})
}
export function fetchWall(page = 1, pageSize = 20): Promise<WallResponse> {
const q = new URLSearchParams({
page: String(page),
page_size: String(pageSize),
})
return request<WallResponse>(`/api/items/wall?${q}`)
}
export function fetchItem(slug: string): Promise<Item> {
return request<Item>(`/api/items/${encodeURIComponent(slug)}`)
}
export function itemFileUrl(slug: string): string {
return `/api/items/${encodeURIComponent(slug)}/file`
}
export function adminLogin(password: string): Promise<{ token: string }> {
return request<{ token: string }>('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
}
function authHeaders(token: string): HeadersInit {
return { Authorization: `Bearer ${token}` }
}
export function adminListItems(token: string): Promise<{ items: Item[] }> {
return request<{ items: Item[] }>('/api/admin/items', {
headers: authHeaders(token),
})
}
export function adminDeleteItem(token: string, itemId: number): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/admin/items/${itemId}`, {
method: 'DELETE',
headers: authHeaders(token),
})
}
export function adminListBans(token: string): Promise<{ bans: Ban[] }> {
return request<{ bans: Ban[] }>('/api/admin/bans', {
headers: authHeaders(token),
})
}
export function adminCreateBan(
token: string,
payload: { ip: string; reason?: string },
): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('/api/admin/bans', {
method: 'POST',
headers: {
...authHeaders(token),
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
}
export function adminDeleteBan(token: string, ip: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>(`/api/admin/bans/${encodeURIComponent(ip)}`, {
method: 'DELETE',
headers: authHeaders(token),
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { createItem, uploadItem, type TTLOption } from '../api/client'
const emit = defineEmits<{ published: [] }>()
const body = ref('')
const ttl = ref<TTLOption>('24h')
const burnAfterRead = ref(false)
const linkOnly = ref(false)
const publishing = ref(false)
const uploading = ref(false)
const shareUrl = ref('')
const accept =
'.png,.jpg,.jpeg,.gif,.webp,.svg,.txt,.md,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.zip,.7z,.tar,.gz,.rar,.json'
const isPublic = computed(() => !linkOnly.value)
function sharePath(slug: string): string {
return `${window.location.origin}/p/${slug}`
}
async function publishText() {
const text = body.value.trim()
if (!text) {
ElMessage.warning('请输入要发布的内容')
return
}
publishing.value = true
try {
const item = await createItem({
body: text,
is_public: isPublic.value,
burn_after_read: burnAfterRead.value,
ttl: ttl.value,
})
shareUrl.value = sharePath(item.slug)
body.value = ''
ElMessage.success('发布成功')
emit('published')
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '发布失败')
} finally {
publishing.value = false
}
}
async function customUpload(options: UploadRequestOptions) {
uploading.value = true
try {
const form = new FormData()
form.append('file', options.file)
form.append('is_public', String(isPublic.value))
form.append('burn_after_read', String(burnAfterRead.value))
form.append('ttl', ttl.value)
const item = await uploadItem(form)
shareUrl.value = sharePath(item.slug)
ElMessage.success('上传成功')
options.onSuccess?.(item as unknown as never)
emit('published')
} catch (err) {
const message = err instanceof Error ? err.message : '上传失败'
ElMessage.error(message)
options.onError?.(err as never)
} finally {
uploading.value = false
}
}
async function copyShareUrl() {
if (!shareUrl.value) return
try {
await navigator.clipboard.writeText(shareUrl.value)
ElMessage.success('链接已复制')
} catch {
ElMessage.error('复制失败,请手动选择链接')
}
}
</script>
<template>
<section class="composer">
<h2>发布</h2>
<el-input
v-model="body"
type="textarea"
:rows="8"
placeholder="支持 Markdown…"
maxlength="20000"
show-word-limit
/>
<div class="controls">
<div class="control-row">
<span class="label">有效期</span>
<el-select v-model="ttl" style="width: 100%">
<el-option label="1 小时" value="1h" />
<el-option label="24 小时" value="24h" />
<el-option label="7 天" value="7d" />
<el-option label="永不" value="never" />
</el-select>
</div>
<div class="switches">
<el-switch v-model="burnAfterRead" active-text="阅后即焚" />
<el-switch v-model="linkOnly" active-text="仅链接可见" />
</div>
</div>
<div class="actions">
<el-button
type="primary"
class="full-width-xs"
:loading="publishing"
@click="publishText"
>
发布
</el-button>
</div>
<el-upload
class="uploader"
drag
:accept="accept"
:show-file-list="false"
:http-request="customUpload"
:disabled="uploading"
>
<div class="upload-inner">
<p>拖拽文件到此处或点击选择</p>
<p class="hint">手机可直接打开文件选择器</p>
</div>
</el-upload>
<div v-if="shareUrl" class="share">
<el-input v-model="shareUrl" readonly>
<template #append>
<el-button @click="copyShareUrl">复制链接</el-button>
</template>
</el-input>
</div>
</section>
</template>
<style scoped>
.composer h2 {
margin: 0 0 12px;
font-size: 1.15rem;
}
.controls {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.control-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.9rem;
color: #606266;
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 16px 20px;
}
.actions {
margin: 14px 0;
}
.uploader {
width: 100%;
}
.uploader :deep(.el-upload),
.uploader :deep(.el-upload-dragger) {
width: 100%;
}
.upload-inner {
padding: 8px;
}
.upload-inner p {
margin: 0;
}
.hint {
margin-top: 6px !important;
font-size: 0.85rem;
color: #909399;
}
.share {
margin-top: 14px;
}
</style>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fetchWall, type Item } from '../api/client'
const router = useRouter()
const items = ref<Item[]>([])
const loading = ref(false)
let timer: number | undefined
async function loadWall(silent = false) {
if (!silent) loading.value = true
try {
const data = await fetchWall(1, 30)
items.value = data.items
} catch (err) {
if (!silent) {
ElMessage.error(err instanceof Error ? err.message : '加载公共墙失败')
}
} finally {
loading.value = false
}
}
function openItem(item: Item) {
router.push(`/p/${item.slug}`)
}
function formatTime(value: string): string {
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return value
}
}
function refresh() {
void loadWall(true)
}
defineExpose({ refresh })
onMounted(() => {
void loadWall()
timer = window.setInterval(() => {
void loadWall(true)
}, 15_000)
})
onUnmounted(() => {
if (timer !== undefined) {
window.clearInterval(timer)
}
})
</script>
<template>
<section class="wall">
<div class="wall-head">
<h2>公共墙</h2>
<el-button text type="primary" :loading="loading" @click="loadWall()">刷新</el-button>
</div>
<el-empty v-if="!loading && items.length === 0" description="暂无公开内容" />
<div v-else class="wall-list">
<button
v-for="item in items"
:key="item.slug"
type="button"
class="wall-item"
@click="openItem(item)"
>
<div class="title">{{ item.title || item.file_name || item.slug }}</div>
<div class="meta">
<span>{{ item.kind === 'file' ? '文件' : '文本' }}</span>
<span>{{ formatTime(item.created_at) }}</span>
<span v-if="item.burn_after_read">阅后即焚</span>
</div>
</button>
</div>
</section>
</template>
<style scoped>
.wall-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.wall-head h2 {
margin: 0;
font-size: 1.15rem;
}
.wall-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.wall-item {
display: block;
width: 100%;
text-align: left;
border: 1px solid #e4e7ed;
background: #fff;
border-radius: 8px;
padding: 14px 12px;
min-height: 56px;
cursor: pointer;
font: inherit;
color: inherit;
}
.wall-item:active {
background: #f0f2f5;
}
.title {
font-weight: 600;
margin-bottom: 6px;
word-break: break-word;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
font-size: 0.85rem;
color: #909399;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles.css'
const app = createApp(App)
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+19
View File
@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ItemView from '../views/ItemView.vue'
import AdminView from '../views/AdminView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/p/:slug', name: 'item', component: ItemView },
{ path: '/admin', name: 'admin', component: AdminView },
],
scrollBehavior() {
return { top: 0 }
},
})
export default router
+67
View File
@@ -0,0 +1,67 @@
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
font-family:
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif;
background: #f5f6f8;
color: #1f2329;
-webkit-tap-highlight-color: transparent;
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 12px 16px 32px;
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.app-brand {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
color: inherit;
}
.app-nav {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.full-width-xs {
width: auto;
}
@media (max-width: 768px) {
.page {
padding: 8px 12px 24px;
}
.desktop-only {
display: none !important;
}
.full-width-xs {
width: 100%;
}
.el-button.full-width-xs {
width: 100%;
}
}
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminCreateBan,
adminDeleteBan,
adminDeleteItem,
adminListBans,
adminListItems,
adminLogin,
type Ban,
type Item,
} from '../api/client'
const TOKEN_KEY = 'mincedpad_admin_token'
const password = ref('')
const token = ref('')
const loggingIn = ref(false)
const loading = ref(false)
const items = ref<Item[]>([])
const bans = ref<Ban[]>([])
const banIp = ref('')
const banReason = ref('')
const banning = ref(false)
const loggedIn = computed(() => Boolean(token.value))
function persistToken(value: string) {
token.value = value
if (value) {
sessionStorage.setItem(TOKEN_KEY, value)
} else {
sessionStorage.removeItem(TOKEN_KEY)
}
}
async function login() {
if (!password.value) {
ElMessage.warning('请输入管理密码')
return
}
loggingIn.value = true
try {
const data = await adminLogin(password.value)
persistToken(data.token)
password.value = ''
await refreshAll()
ElMessage.success('登录成功')
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '登录失败')
} finally {
loggingIn.value = false
}
}
function logout() {
persistToken('')
items.value = []
bans.value = []
}
async function refreshAll() {
if (!token.value) return
loading.value = true
try {
const [itemRes, banRes] = await Promise.all([
adminListItems(token.value),
adminListBans(token.value),
])
items.value = itemRes.items
bans.value = banRes.bans
} catch (err) {
const message = err instanceof Error ? err.message : '加载失败'
ElMessage.error(message)
if (message.toLowerCase().includes('unauthorized') || message.includes('401')) {
logout()
}
} finally {
loading.value = false
}
}
async function removeItem(row: Item) {
if (row.id == null) return
try {
await ElMessageBox.confirm(`确认删除「${row.title || row.slug}」?`, '删除内容', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await adminDeleteItem(token.value, row.id)
ElMessage.success('已删除')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '删除失败')
}
}
async function addBan() {
const ip = banIp.value.trim()
if (!ip) {
ElMessage.warning('请输入 IP')
return
}
banning.value = true
try {
await adminCreateBan(token.value, { ip, reason: banReason.value.trim() })
banIp.value = ''
banReason.value = ''
ElMessage.success('已封禁')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '封禁失败')
} finally {
banning.value = false
}
}
async function removeBan(row: Ban) {
try {
await ElMessageBox.confirm(`解除封禁 ${row.ip}`, '解除封禁', {
type: 'warning',
confirmButtonText: '解除',
cancelButtonText: '取消',
})
} catch {
return
}
try {
await adminDeleteBan(token.value, row.ip)
ElMessage.success('已解除')
await refreshAll()
} catch (err) {
ElMessage.error(err instanceof Error ? err.message : '操作失败')
}
}
function formatTime(value?: string | null): string {
if (!value) return '-'
try {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
} catch {
return value
}
}
onMounted(() => {
const saved = sessionStorage.getItem(TOKEN_KEY)
if (saved) {
token.value = saved
void refreshAll()
}
})
</script>
<template>
<div class="admin">
<h2>管理</h2>
<section v-if="!loggedIn" class="login">
<el-input
v-model="password"
type="password"
show-password
placeholder="管理密码"
size="large"
@keyup.enter="login"
/>
<el-button
type="primary"
class="full-width-xs login-btn"
size="large"
:loading="loggingIn"
@click="login"
>
登录
</el-button>
</section>
<template v-else>
<div class="toolbar">
<el-button class="full-width-xs" :loading="loading" @click="refreshAll">刷新</el-button>
<el-button class="full-width-xs" @click="logout">退出</el-button>
</div>
<h3>内容列表</h3>
<el-table :data="items" v-loading="loading" style="width: 100%" empty-text="暂无内容">
<el-table-column prop="slug" label="标识" min-width="100" />
<el-table-column label="标题" min-width="120">
<template #default="{ row }">
{{ row.title || row.file_name || '-' }}
</template>
</el-table-column>
<el-table-column prop="kind" label="类型" width="70" />
<el-table-column label="IP" min-width="110">
<template #default="{ row }">
{{ row.created_ip || '-' }}
</template>
</el-table-column>
<el-table-column label="时间" min-width="150">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="large" @click="removeItem(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<h3 class="ban-title">IP 封禁</h3>
<div class="ban-form">
<el-input v-model="banIp" placeholder="IP 地址" size="large" />
<el-input v-model="banReason" placeholder="原因(可选)" size="large" />
<el-button
type="primary"
class="full-width-xs"
size="large"
:loading="banning"
@click="addBan"
>
封禁
</el-button>
</div>
<el-table :data="bans" v-loading="loading" style="width: 100%" empty-text="暂无封禁">
<el-table-column prop="ip" label="IP" min-width="120" />
<el-table-column prop="reason" label="原因" min-width="120" />
<el-table-column label="时间" min-width="150">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="100" fixed="right">
<template #default="{ row }">
<el-button size="large" @click="removeBan(row)">解除</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
</template>
<style scoped>
.admin h2 {
margin: 0 0 16px;
font-size: 1.2rem;
}
.admin h3 {
margin: 20px 0 10px;
font-size: 1.05rem;
}
.login {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 360px;
}
.login-btn {
margin-top: 4px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 8px;
}
.ban-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.ban-title {
margin-top: 28px;
}
:deep(.el-button) {
min-height: 40px;
}
@media (min-width: 769px) {
.ban-form {
flex-direction: row;
align-items: center;
}
.ban-form .el-input {
flex: 1;
}
.ban-form .el-button {
width: auto;
}
}
</style>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref } from 'vue'
import Composer from '../components/Composer.vue'
import WallList from '../components/WallList.vue'
const wallRef = ref<InstanceType<typeof WallList> | null>(null)
function onPublished() {
wallRef.value?.refresh()
}
</script>
<template>
<el-row :gutter="16">
<el-col :xs="24" :md="12">
<Composer @published="onPublished" />
</el-col>
<el-col :xs="24" :md="12">
<div class="wall-col">
<WallList ref="wallRef" />
</div>
</el-col>
</el-row>
</template>
<style scoped>
.wall-col {
margin-top: 0;
}
@media (max-width: 991px) {
.wall-col {
margin-top: 24px;
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import MarkdownIt from 'markdown-it'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { fetchItem, itemFileUrl, type Item } from '../api/client'
const md = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
const route = useRoute()
const item = ref<Item | null>(null)
const loading = ref(true)
const error = ref('')
const showBurnAlert = ref(false)
const renderedBody = computed(() => {
if (!item.value?.body) return ''
return md.render(item.value.body)
})
const isImage = computed(() => {
const mime = item.value?.mime || ''
if (!mime.startsWith('image/')) return false
if (mime === 'image/svg+xml') return false
const name = (item.value?.file_name || '').toLowerCase()
return !name.endsWith('.svg')
})
const fileUrl = computed(() => {
if (!item.value || item.value.kind !== 'file') return ''
return itemFileUrl(item.value.slug)
})
async function load() {
const slug = String(route.params.slug || '')
loading.value = true
error.value = ''
item.value = null
showBurnAlert.value = false
try {
const data = await fetchItem(slug)
item.value = data
showBurnAlert.value = data.burn_after_read
} catch (err) {
error.value = err instanceof Error ? err.message : '加载失败'
ElMessage.error(error.value)
} finally {
loading.value = false
}
}
onMounted(load)
watch(
() => route.params.slug,
() => {
void load()
},
)
</script>
<template>
<div v-loading="loading" class="item-view">
<el-alert
v-if="showBurnAlert"
class="burn-alert"
type="warning"
title="阅后即焚"
description="此内容仅可查看一次,离开或刷新后将无法再次打开。"
show-icon
:closable="true"
@close="showBurnAlert = false"
/>
<el-result v-if="!loading && error" icon="warning" :title="error || '未找到'" />
<template v-else-if="item">
<h2 class="title">{{ item.title || item.file_name || item.slug }}</h2>
<div class="meta">
<span>{{ item.kind === 'file' ? '文件' : '文本' }}</span>
<span v-if="item.burn_after_read">阅后即焚</span>
<span v-if="!item.is_public">仅链接可见</span>
</div>
<div v-if="item.kind === 'text'" class="markdown" v-html="renderedBody" />
<div v-else class="file-block">
<template v-if="isImage">
<img class="preview" :src="fileUrl" :alt="item.file_name || item.title" />
</template>
<template v-else>
<p>文件{{ item.file_name || item.slug }}</p>
<el-button type="primary" class="full-width-xs" tag="a" :href="fileUrl" download>
下载文件
</el-button>
</template>
</div>
</template>
</div>
</template>
<style scoped>
.item-view {
min-height: 200px;
}
.burn-alert {
margin-bottom: 16px;
}
.title {
margin: 0 0 8px;
font-size: 1.25rem;
word-break: break-word;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 16px;
color: #909399;
font-size: 0.9rem;
}
.markdown {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.6;
word-break: break-word;
}
.markdown :deep(pre) {
overflow-x: auto;
padding: 10px;
background: #f5f7fa;
border-radius: 6px;
}
.markdown :deep(img) {
max-width: 100%;
height: auto;
}
.file-block {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 14px;
}
.preview {
display: block;
max-width: 100%;
height: auto;
border-radius: 4px;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})