diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c355999 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b2c90b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..78321f1 --- /dev/null +++ b/README.md @@ -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. diff --git a/backend/app/main.py b/backend/app/main.py index dcbcd05..e2e629b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,10 @@ 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 @@ -11,6 +14,20 @@ 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() @@ -47,3 +64,19 @@ 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") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea9d63e --- /dev/null +++ b/docker-compose.yml @@ -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