Merge pull request #7 from velocitatem/claude/public-ready-setup-n0Sbb

Prepare repository for public deployment
This commit is contained in:
Daniel Alves Rösel
2026-04-04 14:19:55 +04:00
committed by GitHub
7 changed files with 245 additions and 165 deletions

View File

@@ -1,66 +1,58 @@
NAME=myproject # Resume Branches — environment configuration
COMPOSE_PROJECT_NAME=$NAME # 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 # ── General ───────────────────────────────────────────────────────────────────
BACKEND_MODE=fastapi NAME=cvfs
BACKEND_PORT=9812 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 DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/resume_branches
# Comma-separated list of allowed CORS origins
CORS_ORIGINS=http://localhost:3000 CORS_ORIGINS=http://localhost:3000
# Ports # ── PostgreSQL ────────────────────────────────────────────────────────────────
REDIS_PORT=6378
GRAFANA_PORT=3125
LOKI_PORT=3142
# PostgreSQL
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
# MongoDB # ── Redis ─────────────────────────────────────────────────────────────────────
MONGO_PORT=27017 REDIS_URL=redis://localhost:6379/0
MONGO_DB=app
MONGO_USER=admin
MONGO_PASSWORD=admin123
MONGO_HOST=localhost
DATABASE_TYPE=postgres # ── MinIO object storage ──────────────────────────────────────────────────────
# Internal URL used by backend/worker (keep as-is for Docker deployments).
# Redis MINIO_ENDPOINT=http://localhost:9000
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_BUCKET=resume-branches MINIO_BUCKET=resume-branches
MINIO_REGION=us-east-1 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 # ── Frontend port (standalone mode only) ─────────────────────────────────────
ML_LATEST_WEIGHTS_PATH=/app/models/weights WEBAPP_PORT=3000
MLFLOW_TRACKING_URI=http://localhost:5000
# AI / Agents # ── Auth — OIDC (optional) ────────────────────────────────────────────────────
ANTHROPIC_API_KEY=sk-ant-... # Set AUTH_DISABLE_VERIFICATION=false and configure OIDC to require authentication.
# Auth / Publishing # Any OIDC-compatible provider works (Authentik, Keycloak, Auth0, Zitadel, etc.).
PUBLIC_BASE_URL=https://cv.alves.world
CV_PUBLIC_DOMAIN=cv.alves.world
AUTH_DISABLE_VERIFICATION=true AUTH_DISABLE_VERIFICATION=true
# AUTH_OIDC_ISSUER= AUTH_OIDC_ISSUER=
# AUTH_OIDC_AUDIENCE= AUTH_OIDC_AUDIENCE=
# Optional: use Bedrock instead of direct Anthropic API
# CLAUDE_CODE_USE_BEDROCK=1 # Frontend OIDC config (baked into the Next.js build — requires rebuild on change)
# Optional: use Vertex AI NEXT_PUBLIC_AUTHENTIK_ISSUER=
# CLAUDE_CODE_USE_VERTEX=1 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=

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import selectinload
from dlib.cv import parse_docx_bytes from dlib.cv import parse_docx_bytes
from app.models import CvDocument, CvVersion, PublicAsset 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( async def create_document(
@@ -93,11 +93,14 @@ async def delete_document(
doc = await get_document(session, owner_id, document_id) doc = await get_document(session, owner_id, document_id)
if not doc: if not doc:
return False 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: if version_ids:
await session.execute( await session.execute(
delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids)) delete(PublicAsset).where(PublicAsset.version_id.in_(version_ids))
) )
await session.delete(doc) await session.delete(doc)
await session.commit() await session.commit()
for key in artifact_keys:
storage_client.delete_object(key=key)
return True return True

View File

@@ -5,32 +5,25 @@ export async function GET(
{ params }: { params: Promise<{ slug: string }> } { params }: { params: Promise<{ slug: string }> }
) { ) {
const { slug } = await params; 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 { try {
const res = await fetch(`${backend}/api/v1/public/${slug}`, { const res = await fetch(`${backend}/api/v1/public/${encodeURIComponent(slug)}/pdf`, {
cache: 'no-store' cache: 'no-store',
}); });
if (!res.ok) { if (res.status === 404) return new NextResponse('CV not found', { status: 404 });
return new NextResponse('CV not found', { status: 404 }); if (!res.ok) return new NextResponse('Failed to fetch CV', { status: res.status });
}
const data = await res.json(); const pdf = await res.arrayBuffer();
if (!data.asset || !data.asset.artifact_key) { return new NextResponse(pdf, {
return new NextResponse('CV not found', { status: 404 }); status: 200,
} headers: {
'Content-Type': 'application/pdf',
// Construct MinIO public URL 'Content-Disposition': `inline; filename="${slug}.pdf"`,
const storageHost = process.env.MINIO_ENDPOINT || (process.env.NODE_ENV === 'production' 'Cache-Control': 'public, max-age=300',
? '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) { } catch (error) {
console.error('Error fetching public CV:', error); console.error('Error fetching public CV:', error);
return new NextResponse('Internal Server Error', { status: 500 }); return new NextResponse('Internal Server Error', { status: 500 });

View File

@@ -1,79 +1,21 @@
from __future__ import annotations from __future__ import annotations
from io import BytesIO import os
import subprocess
from docx import Document import tempfile
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"),
),
}
def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes: def docx_bytes_to_pdf(docx_bytes: bytes) -> bytes:
doc = Document(BytesIO(docx_bytes)) with tempfile.TemporaryDirectory() as tmpdir:
buf = BytesIO() docx_path = os.path.join(tmpdir, "cv.docx")
pdf = SimpleDocTemplate( pdf_path = os.path.join(tmpdir, "cv.pdf")
buf, pagesize=A4, with open(docx_path, "wb") as f:
leftMargin=2.2 * cm, rightMargin=2.2 * cm, f.write(docx_bytes)
topMargin=2 * cm, bottomMargin=2 * cm, subprocess.run(
) ["libreoffice", "--headless", "--convert-to", "pdf", "--outdir", tmpdir, docx_path],
story: list = [] check=True,
prev_type: str | None = None capture_output=True,
timeout=60,
for para in doc.paragraphs: )
text = para.text.strip() with open(pdf_path, "rb") as f:
if not text: return f.read()
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()

View File

@@ -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:

View File

@@ -43,19 +43,20 @@ services:
environment: environment:
- BACKEND_PORT=8080 - BACKEND_PORT=8080
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches - DATABASE_URL=postgresql+asyncpg://postgres:postgres@cvfs-postgres:5432/resume_branches
- MINIO_ENDPOINT=https://storage.cv.alves.world - MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=us-east-1 - MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PUBLIC_BASE_URL=https://cv.alves.world - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-https://cv.alves.world}
- CV_PUBLIC_DOMAIN=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 - REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER} - AUTH_OIDC_ISSUER=${AUTH_OIDC_ISSUER:-}
- AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE} - AUTH_OIDC_AUDIENCE=${AUTH_OIDC_AUDIENCE:-}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false} - AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on: depends_on:
- postgres - postgres
@@ -81,9 +82,9 @@ services:
- REDIS_URL=redis://cvfs-redis:6379/0 - REDIS_URL=redis://cvfs-redis:6379/0
- CELERY_BROKER_URL=redis://cvfs-redis:6379/0 - CELERY_BROKER_URL=redis://cvfs-redis:6379/0
- CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0 - CELERY_RESULT_BACKEND=redis://cvfs-redis:6379/0
- MINIO_ENDPOINT=https://storage.cv.alves.world - MINIO_ENDPOINT=http://cvfs-minio:9000
- MINIO_BUCKET=resume-branches - MINIO_BUCKET=${MINIO_BUCKET:-resume-branches}
- MINIO_REGION=us-east-1 - MINIO_REGION=${MINIO_REGION:-us-east-1}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin}
- PYTHONPATH=/app - PYTHONPATH=/app
@@ -128,7 +129,6 @@ services:
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
networks: networks:
- cvfs-network - cvfs-network
- dokploy-network
restart: unless-stopped restart: unless-stopped
create-bucket: create-bucket:

View File

@@ -12,6 +12,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
libreoffice-writer \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock requirements.txt ./ COPY pyproject.toml uv.lock requirements.txt ./