From aa419cde0dd39740c5996cd45790d8843a8c21ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 10:06:20 +0000 Subject: [PATCH] Prepare repository for public deployment - Replace ReportLab PDF export with LibreOffice headless for proper DOCX formatting preservation - Add libreoffice-writer + fonts-liberation to backend Dockerfile - Proxy public CV PDFs through frontend (/cv/[slug]) instead of redirecting to MinIO storage directly - Fix docker-compose: route backend/worker to internal MinIO URL (http://cvfs-minio:9000), remove MinIO from public network, parameterize all domain/env vars - Add storage cleanup (MinIO artifact deletion) when a document is deleted - Add docker-compose.standalone.yml for self-deployment without Traefik/dokploy - Update .env.example with comprehensive self-deployment documentation https://claude.ai/code/session_017HGM9VPptZG52asT5pbL6Y --- .env.example | 100 ++++++------ .../backend/fastapi/app/services/documents.py | 7 +- apps/webapp/src/app/cv/[slug]/route.ts | 41 ++--- dlib/cv/pdf_export.py | 90 ++--------- docker-compose.standalone.yml | 148 ++++++++++++++++++ docker-compose.yml | 22 +-- docker/backend-fastapi.Dockerfile | 2 + 7 files changed, 245 insertions(+), 165 deletions(-) create mode 100644 docker-compose.standalone.yml diff --git a/.env.example b/.env.example index 0675da0..48b9b6d 100644 --- a/.env.example +++ b/.env.example @@ -1,66 +1,58 @@ -NAME=myproject -COMPOSE_PROJECT_NAME=$NAME +# Resume Branches — environment configuration +# Copy this file to .env and fill in values before running docker compose. +# For standalone (no Traefik): docker compose -f docker-compose.standalone.yml up -d +# For Traefik-based production: docker compose up -d (edit Traefik labels in docker-compose.yml) -# Backend -BACKEND_MODE=fastapi -BACKEND_PORT=9812 +# ── General ─────────────────────────────────────────────────────────────────── +NAME=cvfs +COMPOSE_PROJECT_NAME=cvfs + +# ── Public URLs ─────────────────────────────────────────────────────────────── +# The URL users visit to access the app (no trailing slash). +# Standalone local: http://localhost:3000 +# Production with a domain: https://cv.example.com +PUBLIC_BASE_URL=http://localhost:3000 + +# Domain used to construct published CV links (hostname only, no scheme). +CV_PUBLIC_DOMAIN=localhost + +# ── Backend ─────────────────────────────────────────────────────────────────── +BACKEND_PORT=8080 DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches +# Comma-separated list of allowed CORS origins CORS_ORIGINS=http://localhost:3000 -# Ports -REDIS_PORT=6378 -GRAFANA_PORT=3125 -LOKI_PORT=3142 - -# PostgreSQL -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres +# ── PostgreSQL ──────────────────────────────────────────────────────────────── POSTGRES_PASSWORD=postgres -POSTGRES_HOST=localhost -# MongoDB -MONGO_PORT=27017 -MONGO_DB=app -MONGO_USER=admin -MONGO_PASSWORD=admin123 -MONGO_HOST=localhost +# ── Redis ───────────────────────────────────────────────────────────────────── +REDIS_URL=redis://localhost:6379/0 -DATABASE_TYPE=postgres - -# Redis -REDIS_URL=redis://localhost:$REDIS_PORT - -# Logging -LOGDIR="/tmp/logs-$NAME/" - -# Supabase (webapp auth - set NEXT_PUBLIC_REQUIRE_AUTH=true to enable gating) -NEXT_PUBLIC_REQUIRE_AUTH=false -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co -NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_supabase_anon_key_here -# Server-side proxy target (read by next.config.ts at runtime, not baked into the bundle) -API_BASE_URL=http://localhost:9812 - -# MinIO Object Storage (used instead of S3) -MINIO_ROOT_USER=minioadmin -MINIO_ROOT_PASSWORD=minioadmin -MINIO_ENDPOINT=http://localhost:9900 +# ── MinIO object storage ────────────────────────────────────────────────────── +# Internal URL used by backend/worker (keep as-is for Docker deployments). +MINIO_ENDPOINT=http://localhost:9000 MINIO_BUCKET=resume-branches MINIO_REGION=us-east-1 +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +# MinIO admin console port (standalone mode only) +MINIO_CONSOLE_PORT=9001 -# ML -ML_LATEST_WEIGHTS_PATH=/app/models/weights -MLFLOW_TRACKING_URI=http://localhost:5000 +# ── Frontend port (standalone mode only) ───────────────────────────────────── +WEBAPP_PORT=3000 -# AI / Agents -ANTHROPIC_API_KEY=sk-ant-... -# Auth / Publishing -PUBLIC_BASE_URL=https://cv.alves.world -CV_PUBLIC_DOMAIN=cv.alves.world +# ── Auth — OIDC (optional) ──────────────────────────────────────────────────── +# Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication. +# Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.). AUTH_DISABLE_VERIFICATION=true -# AUTH_OIDC_ISSUER= -# AUTH_OIDC_AUDIENCE= -# Optional: use Bedrock instead of direct Anthropic API -# CLAUDE_CODE_USE_BEDROCK=1 -# Optional: use Vertex AI -# CLAUDE_CODE_USE_VERTEX=1 +AUTH_OIDC_ISSUER= +AUTH_OIDC_AUDIENCE= + +# Frontend OIDC config (baked into the Next.js build — requires rebuild on change) +NEXT_PUBLIC_AUTHENTIK_ISSUER= +NEXT_PUBLIC_AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= + +# ── AI tailoring (optional) ─────────────────────────────────────────────────── +# Leave blank to use the built-in rule-based tailoring instead of Claude. +ANTHROPIC_API_KEY= diff --git a/apps/backend/fastapi/app/services/documents.py b/apps/backend/fastapi/app/services/documents.py index 739bad0..76ca4ff 100644 --- a/apps/backend/fastapi/app/services/documents.py +++ b/apps/backend/fastapi/app/services/documents.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload from dlib.cv import parse_docx_bytes from app.models import CvDocument, CvVersion, PublicAsset -from app.services.storage import persist_upload +from app.services.storage import persist_upload, storage_client async def create_document( @@ -93,11 +93,14 @@ async def delete_document( doc = await get_document(session, owner_id, document_id) if not doc: return False - version_ids = [version.id for version in doc.versions] + artifact_keys = {v.artifact_docx_key for v in doc.versions if v.artifact_docx_key} + version_ids = [v.id for v in doc.versions] if version_ids: await session.execute( delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids)) ) await session.delete(doc) await session.commit() + for key in artifact_keys: + storage_client.delete_object(key=key) return True diff --git a/apps/webapp/src/app/cv/[slug]/route.ts b/apps/webapp/src/app/cv/[slug]/route.ts index 2d2772b..6f6a0ec 100644 --- a/apps/webapp/src/app/cv/[slug]/route.ts +++ b/apps/webapp/src/app/cv/[slug]/route.ts @@ -5,32 +5,25 @@ export async function GET( { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; - - const backend = process.env.API_BASE_URL ?? "http://localhost:9812"; - + const backend = process.env.API_BASE_URL ?? 'http://localhost:9812'; + try { - const res = await fetch(`${backend}/api/v1/public/${slug}`, { - cache: 'no-store' + const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, { + cache: 'no-store', + }); + + if (res.status === 404) return new NextResponse('CV not found', { status: 404 }); + if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status }); + + const pdf = await res.arrayBuffer(); + return new NextResponse(pdf, { + status: 200, + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${slug}.pdf"`, + 'Cache-Control': 'public, max-age=300', + }, }); - - if (!res.ok) { - return new NextResponse('CV not found', { status: 404 }); - } - - const data = await res.json(); - if (!data.asset || !data.asset.artifact_key) { - return new NextResponse('CV not found', { status: 404 }); - } - - // Construct MinIO public URL - const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production' - ? 'https://storage.cv.alves.world' - : 'http://localhost:9900'); - - const bucket = process.env.MINIO_BUCKET || 'resume-branches'; - const downloadUrl = `${storageHost}/${bucket}/${data.asset.artifact_key}`; - - return NextResponse.redirect(downloadUrl); } catch (error) { console.error('Error fetching public CV:', error); return new NextResponse('Internal Server Error', { status: 500 }); diff --git a/dlib/cv/pdf_export.py b/dlib/cv/pdf_export.py index dbce746..2e869c6 100644 --- a/dlib/cv/pdf_export.py +++ b/dlib/cv/pdf_export.py @@ -1,79 +1,21 @@ from __future__ import annotations -from io import BytesIO - -from docx import Document -from reportlab.lib import colors -from reportlab.lib.enums import TA_CENTER, TA_LEFT -from reportlab.lib.pagesizes import A4 -from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet -from reportlab.lib.units import cm -from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate, Spacer - -from .parser import _detect_block_type - -_STYLES = getSampleStyleSheet() - -_STYLE_MAP: dict[str, ParagraphStyle] = { - "heading": ParagraphStyle( - "CVHeading", parent=_STYLES["Normal"], - fontSize=15, leading=20, spaceBefore=10, spaceAfter=4, - textColor=colors.HexColor("#111111"), fontName="Helvetica-Bold", - ), - "meta": ParagraphStyle( - "CVMeta", parent=_STYLES["Normal"], - fontSize=9, leading=13, spaceAfter=2, textColor=colors.HexColor("#555555"), - ), - "summary": ParagraphStyle( - "CVSummary", parent=_STYLES["Normal"], - fontSize=10, leading=14, spaceAfter=6, textColor=colors.HexColor("#333333"), - ), - "bullet": ParagraphStyle( - "CVBullet", parent=_STYLES["Normal"], - fontSize=10, leading=14, spaceAfter=3, leftIndent=14, - bulletIndent=0, textColor=colors.HexColor("#222222"), - ), - "skills": ParagraphStyle( - "CVSkills", parent=_STYLES["Normal"], - fontSize=10, leading=14, spaceAfter=3, textColor=colors.HexColor("#222222"), - ), - "text": ParagraphStyle( - "CVText", parent=_STYLES["Normal"], - fontSize=10, leading=14, spaceAfter=4, textColor=colors.HexColor("#222222"), - ), -} +import os +import subprocess +import tempfile def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes: - doc = Document(BytesIO(docx_bytes)) - buf = BytesIO() - pdf = SimpleDocTemplate( - buf, pagesize=A4, - leftMargin=2.2 * cm, rightMargin=2.2 * cm, - topMargin=2 * cm, bottomMargin=2 * cm, - ) - story: list = [] - prev_type: str | None = None - - for para in doc.paragraphs: - text = para.text.strip() - if not text: - if prev_type and prev_type != "empty": - story.append(Spacer(1, 4)) - prev_type = "empty" - continue - - block_type = _detect_block_type(getattr(para.style, "name", None), para) - style = _STYLE_MAP.get(block_type, _STYLE_MAP["text"]) - - # draw separator line before new heading sections (except first) - if block_type == "heading" and prev_type not in (None, "heading"): - story.append(Spacer(1, 6)) - story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#cccccc"))) - - prefix = "\u2022\u00a0" if block_type == "bullet" else "" - story.append(Paragraph(f"{prefix}{text}", style)) - prev_type = block_type - - pdf.build(story) - return buf.getvalue() + with tempfile.TemporaryDirectory() as tmpdir: + docx_path = os.path.join(tmpdir, "cv.docx") + pdf_path = os.path.join(tmpdir, "cv.pdf") + with open(docx_path, "wb") as f: + f.write(docx_bytes) + subprocess.run( + ["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path], + check=True, + capture_output=True, + timeout=60, + ) + with open(pdf_path, "rb") as f: + return f.read() diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml new file mode 100644 index 0000000..7c18f81 --- /dev/null +++ b/docker-compose.standalone.yml @@ -0,0 +1,148 @@ +version: "3.8" + +# Standalone deployment — no Traefik/reverse-proxy required. +# Usage: docker compose -f docker-compose.standalone.yml up -d +# Configure via a .env file (copy .env.example and fill in values). + +networks: + cvfs-network: + +services: + webapp: + container_name: "cvfs-webapp" + build: + context: ./ + dockerfile: ./docker/webapp.Dockerfile + args: + NEXT_PUBLIC_AUTHENTIK_ISSUER: ${NEXT_PUBLIC_AUTHENTIK_ISSUER:-} + NEXT_PUBLIC_AUTHENTIK_CLIENT_ID: ${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-} + NEXT_PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:3000} + API_BASE_URL: http://cvfs-backend:8080 + environment: + - API_BASE_URL=http://cvfs-backend:8080 + - AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER:-} + - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-} + - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-} + - NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER:-} + - NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID:-} + - NEXT_PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000} + ports: + - "${WEBAPP_PORT:-3000}:3000" + networks: + - cvfs-network + depends_on: + - backend + restart: unless-stopped + + backend: + container_name: "cvfs-backend" + build: + context: ./ + dockerfile: ./docker/backend-fastapi.Dockerfile + environment: + - BACKEND_PORT=8080 + - DATABASE_URL=postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@cvfs-postgres:5432/resume_branches + - MINIO_ENDPOINT=http://cvfs-minio:9000 + - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches} + - MINIO_REGION=${MINIO_REGION:-us-east-1} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://localhost:3000} + - CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-localhost} + - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} + - REDIS_URL=redis://cvfs-redis:6379/0 + - CELERY_BROKER_URL=redis://cvfs-redis:6379/0 + - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-} + - AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-} + - AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-true} + ports: + - "${BACKEND_PORT:-8080}:8080" + depends_on: + - postgres + - minio + - redis + networks: + - cvfs-network + restart: unless-stopped + + worker: + container_name: "cvfs-worker" + build: + context: ./ + dockerfile: ./docker/worker.Dockerfile + environment: + - REDIS_URL=redis://cvfs-redis:6379/0 + - CELERY_BROKER_URL=redis://cvfs-redis:6379/0 + - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 + - MINIO_ENDPOINT=http://cvfs-minio:9000 + - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches} + - MINIO_REGION=${MINIO_REGION:-us-east-1} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} + - PYTHONPATH=/app + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + depends_on: + - redis + - minio + networks: + - cvfs-network + restart: unless-stopped + + redis: + container_name: "cvfs-redis" + image: redis:7-alpine + volumes: + - redis_data:/data + networks: + - cvfs-network + restart: unless-stopped + + postgres: + image: postgres:15-alpine + container_name: "cvfs-postgres" + environment: + POSTGRES_DB: resume_branches + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - cvfs-network + restart: unless-stopped + + minio: + image: minio/minio:latest + container_name: "cvfs-minio" + environment: + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} + volumes: + - minio_data:/data + command: server /data --console-address ":9001" + ports: + - "${MINIO_CONSOLE_PORT:-9001}:9001" + networks: + - cvfs-network + restart: unless-stopped + + create-bucket: + image: minio/mc + container_name: "cvfs-create-bucket" + depends_on: + - minio + networks: + - cvfs-network + entrypoint: > + /bin/sh -c " + sleep 5; + mc alias set myminio http://cvfs-minio:9000 $${MINIO_ROOT_USER:-minioadmin} $${MINIO_ROOT_PASSWORD:-minioadmin}; + mc mb myminio/$${MINIO_BUCKET:-resume-branches} --ignore-existing; + exit 0; + " + +volumes: + redis_data: + postgres_data: + minio_data: diff --git a/docker-compose.yml b/docker-compose.yml index 4877db4..3fa361f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,19 +43,20 @@ services: environment: - BACKEND_PORT=8080 - DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches - - MINIO_ENDPOINT=https://storage.cv.alves.world - - MINIO_BUCKET=resume-branches - - MINIO_REGION=us-east-1 + - MINIO_ENDPOINT=http://cvfs-minio:9000 + - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches} + - MINIO_REGION=${MINIO_REGION:-us-east-1} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - - PUBLIC_BASE_URL=https://cv.alves.world - - CV_PUBLIC_DOMAIN=cv.alves.world + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world} + - CV_PUBLIC_DOMAIN=${CV_PUBLIC_DOMAIN:-cv.alves.world} + - CORS_ORIGINS=${CORS_ORIGINS:-https://cv.alves.world} - REDIS_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - - AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER} - - AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE} + - AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-} + - AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-} - AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false} depends_on: - postgres @@ -81,9 +82,9 @@ services: - REDIS_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - - MINIO_ENDPOINT=https://storage.cv.alves.world - - MINIO_BUCKET=resume-branches - - MINIO_REGION=us-east-1 + - MINIO_ENDPOINT=http://cvfs-minio:9000 + - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches} + - MINIO_REGION=${MINIO_REGION:-us-east-1} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - PYTHONPATH=/app @@ -128,7 +129,6 @@ services: command: server /data --console-address ":9001" networks: - cvfs-network - - dokploy-network restart: unless-stopped create-bucket: diff --git a/docker/backend-fastapi.Dockerfile b/docker/backend-fastapi.Dockerfile index 9120393..3dbc792 100644 --- a/docker/backend-fastapi.Dockerfile +++ b/docker/backend-fastapi.Dockerfile @@ -12,6 +12,8 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ libpq-dev \ + libreoffice-writer \ + fonts-liberation \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml uv.lock requirements.txt ./