diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80f2640 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/__pycache__ +**/*.pyc +frontend/dist +storage +guides.db +.git +.claude-data diff --git a/.gitignore b/.gitignore index 7affc33..9770bd2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ frontend/dist/ __pycache__/ *.pyc +.claude-data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86f1c32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Stage 1: Frontend bauen +FROM node:20-alpine AS frontend +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Runtime +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + poppler-utils \ + libpango-1.0-0 \ + libpangoft2-1.0-0 \ + libharfbuzz0b \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + fonts-dejavu \ + curl \ + ca-certificates \ + gnupg \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g @anthropic-ai/claude-code \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 1000 app + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/backend/requirements.txt + +COPY --chown=app:app backend/ /app/backend/ +COPY --chown=app:app templates/ /app/templates/ +COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist + +RUN chown app:app /app + +USER app +WORKDIR /app/backend + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 24f9c03..dbcfb86 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,12 @@ -.PHONY: install dev prod stop remove +.PHONY: install dev prod stop logs remove auth + +COMPOSE = docker compose + +auth: + @mkdir -p .claude-data storage + @cp -aT /root/.claude .claude-data + @chown -R 1000:1000 .claude-data storage + @echo "Claude-Auth nach .claude-data synct, storage chowned auf uid 1000." install: sudo apt install -y poppler-utils libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev @@ -11,18 +19,19 @@ dev: @cd backend && uvicorn main:app --reload --port 8000 & @cd frontend && npx vite --port 5173 -prod: - @echo "Backend: http://localhost:8000" - @cd frontend && npx vite build - @cd backend && uvicorn main:app --host 0.0.0.0 --port 8000 +prod: auth + $(COMPOSE) up -d --build stop: -@pkill -f "uvicorn main:app" 2>/dev/null -@pkill -f "vite --port 5173" 2>/dev/null + $(COMPOSE) down @echo "Server gestoppt." +logs: + $(COMPOSE) logs -f + remove: stop @echo "Lösche Datenbank und generierte Dateien..." - rm -f guides.db - rm -rf storage/html/* storage/pdf/* + rm -rf storage/* @echo "Fertig." diff --git a/backend/config.py b/backend/config.py index 38aec00..83f90c5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -3,7 +3,8 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent TEMPLATES_DIR = PROJECT_ROOT / "templates" STORAGE_DIR = PROJECT_ROOT / "storage" -DB_PATH = PROJECT_ROOT / "guides.db" +FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist" +DB_PATH = STORAGE_DIR / "guides.db" ALLOWED_FORMATS = [ "OnePager", @@ -24,15 +25,15 @@ FORMAT_META = { } GENERATION_TIMEOUTS = { - "OnePager": 600, - "Cheatsheet": 600, - "MiniGuide": 600, - "BeginnerGuide": 900, - "IntermediateGuide": 1200, - "ExtendedGuide": 1500, + "OnePager": 900, + "Cheatsheet": 900, + "MiniGuide": 1200, + "BeginnerGuide": 1800, + "IntermediateGuide": 2400, + "ExtendedGuide": 3000, } -MAX_CONCURRENT_GENERATIONS = 10 +MAX_CONCURRENT_GENERATIONS = 6 MAX_ITERATIONS = { "OnePager": 3, "Cheatsheet": 3, diff --git a/backend/generator.py b/backend/generator.py index c9132fd..e056e40 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -46,10 +46,18 @@ async def _run_claude(guide_id: str, prompt: str, timeout: int) -> tuple[int, st ) _active_processes[guide_id] = process try: - stdout, stderr = await asyncio.wait_for( - process.communicate(input=prompt.encode("utf-8")), - timeout=timeout, - ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(input=prompt.encode("utf-8")), + timeout=timeout, + ) + except asyncio.TimeoutError: + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=5) + except asyncio.TimeoutError: + pass + raise return process.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") finally: _active_processes.pop(guide_id, None) diff --git a/backend/main.py b/backend/main.py index 13f51c9..3073150 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,9 @@ from contextlib import asynccontextmanager from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles -from config import STORAGE_DIR +from config import FRONTEND_DIST, STORAGE_DIR from database import init_db, close_db from routes import router @@ -20,11 +20,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Guides Generator", lifespan=lifespan) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) - app.include_router(router) + +if FRONTEND_DIST.exists(): + app.mount("/", StaticFiles(directory=FRONTEND_DIST, html=True), name="frontend") diff --git a/backend/requirements.txt b/backend/requirements.txt index 231d37c..5fb99e6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,5 @@ fastapi uvicorn[standard] aiosqlite +weasyprint +pdf2image diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e346227 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + guides: + build: + context: . + container_name: guides + restart: unless-stopped + networks: + - web + volumes: + - ./storage:/app/storage + - ./.claude-data:/home/app/.claude + labels: + - "traefik.enable=true" + - "traefik.http.routers.guidesapp.rule=Host(`guides.marha.de`)" + - "traefik.http.routers.guidesapp.entrypoints=websecure" + - "traefik.http.routers.guidesapp.tls.certresolver=letsencrypt" + - "traefik.http.services.guidesapp.loadbalancer.server.port=8000" + +networks: + web: + external: true