Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c2489ea5c | |||
| 600c536367 | |||
| 445a940f7d | |||
| 81d6b377c7 | |||
| c92bcb0074 | |||
| 4d697692e0 | |||
| 175a4ad0d1 | |||
| f2a1f3a1d6 | |||
| eda7010cae | |||
| 0e59936e34 |
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
+62
-3
@@ -1,10 +1,43 @@
|
||||
import asyncio
|
||||
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.db import Base, engine
|
||||
from app.db import Base, SessionLocal, engine
|
||||
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
|
||||
@@ -12,12 +45,38 @@ async def lifespan(_app: FastAPI):
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
task = asyncio.create_task(_cleaner_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="MincedPad", lifespan=lifespan)
|
||||
app.include_router(public_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+2079
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user