Compare commits

..

31 Commits

Author SHA1 Message Date
Claude
bdf9b25544 chore: ignore *.db files (local SQLite test databases)
https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:32 +00:00
Claude
1b11cdf25c Fix document loading: circular FK, patch validation 500, and token expiry redirect
- documents.py: fix root_version_id never being saved due to SQLAlchemy
  deferring the circular FK (CvDocument↔CvVersion). Use flush-based approach:
  flush doc, flush version, then set root_version_id before final commit.
- config.py: add validator to coerce empty MINIO_ENDPOINT string to None,
  preventing boto3 ValueError: Invalid endpoint at startup.
- versions.py: catch PatchValidationError and return 422 instead of 500
  when a patch violates ATS guard rules.
- api.ts: on 401, clear stale OIDC cookies and redirect to /login instead
  of showing the "Failed to load documents" error.

https://claude.ai/code/session_01KKbzWYz8fLyG2qcwiDZ8fy
2026-04-04 07:43:00 +00:00
Daniel Alves Rösel
1a261be792 Merge pull request #5 from velocitatem/copilot/fix-404-page-sidebar-issue
fix: "404 page not found" in dashboard sidebar after login
2026-04-04 10:48:26 +04:00
copilot-swe-agent[bot]
0b38f9f703 fix: correct default API_BASE_URL and improve sidebar error message
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/1a9aa3e0-c4e1-48fb-b646-49f31c316325

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:35:18 +00:00
copilot-swe-agent[bot]
8bc501fa85 Initial plan 2026-04-04 06:19:11 +00:00
Daniel Alves Rösel
b18ad7712c Merge pull request #4 from velocitatem/copilot/add-pdf-rendering-and-analytics
feat: CV share analytics + PDF rendering for public links
2026-04-04 10:05:35 +04:00
copilot-swe-agent[bot]
ad91369371 fix: address code review - use timezone-aware datetime, full ip hash
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 06:02:24 +00:00
copilot-swe-agent[bot]
7435a0f1bf feat: add CV view analytics and PDF rendering for public share links
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-04 05:59:05 +00:00
copilot-swe-agent[bot]
b63417b8b3 Initial plan 2026-04-04 05:50:23 +00:00
66bf016747 verify tokens using full jwks and relaxed options 2026-04-03 19:51:48 +02:00
dfc3764bcc normalize discovery issuer path 2026-04-03 19:45:20 +02:00
b5053c5536 strip authorize suffix from issuer 2026-04-03 19:37:49 +02:00
d2ad0c3fdd use raw issuer for discovery 2026-04-03 19:36:22 +02:00
effb9161f8 use kid-specific jwk for verification 2026-04-03 19:33:56 +02:00
fa215009cd allow jwt alg from token header 2026-04-03 19:31:23 +02:00
e7bac3b178 discover jwks uri from oidc configuration 2026-04-03 19:28:03 +02:00
ba0612efb8 parse authentik issuer path correctly 2026-04-03 19:24:54 +02:00
7e5f2bb06a fix authentik issuer normalization 2026-04-03 19:22:51 +02:00
5a8e8f1572 force authentik issuer base 2026-04-03 19:21:13 +02:00
9f90b000e2 normalize oidc issuer for authentik 2026-04-03 19:18:27 +02:00
dce592c086 redirect using public base url 2026-04-03 19:15:37 +02:00
531c27b669 use authentik host for authorize urls 2026-04-03 19:13:28 +02:00
81165ca9db normalize authentik issuer paths 2026-04-03 19:04:43 +02:00
3f6b9a4f81 propagate authentik env into web build 2026-04-03 19:01:17 +02:00
dcfe207389 wire authentik env vars in compose 2026-04-03 18:47:42 +02:00
5ccae82cfd load env file for compose services 2026-04-03 18:45:32 +02:00
af7a9cb63f fix cascade delete on cv versions 2026-04-03 18:28:54 +02:00
Daniel Alves Rösel
d6e5e563f1 Merge pull request #3 from velocitatem/copilot/add-delete-buttons-and-fix-header
Rename to cvfs, remove dashboard heading, add branch delete buttons
2026-04-03 19:17:40 +04:00
copilot-swe-agent[bot]
77d454cf09 rename to cvfs, remove dashboard heading, add branch delete buttons
Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/2bd56e04-d1e0-4e38-93b6-a99afc1d2b3c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
2026-04-03 15:08:05 +00:00
copilot-swe-agent[bot]
7543402c83 Initial plan 2026-04-03 15:03:54 +00:00
Daniel Alves Rösel
83b609f815 Merge pull request #2 from velocitatem/copilot/add-mobile-support-and-deleting-functions
Add mobile support, CV/branch deletion, and fix DOCX export to include patches
2026-04-03 18:54:47 +04:00
26 changed files with 415 additions and 90 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ logs/
FLAT.xml
**/package-lock.json
.nx/
**/*.db

View File

@@ -1,20 +1,53 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"])
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest() if ip else None
view = PublicAssetView(
public_asset_id=asset.id,
viewed_at=datetime.now(timezone.utc),
user_agent=request.headers.get("user-agent", "")[:512] or None,
ip_hash=ip_hash,
)
session.add(view)
await session.commit()
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
@@ -33,21 +66,78 @@ async def publish(
return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
)
).scalar() or 0
last_viewed_at = (
await session.execute(
select(PublicAssetView.viewed_at)
.where(PublicAssetView.public_asset_id == asset.id)
.order_by(PublicAssetView.viewed_at.desc())
.limit(1)
)
).scalar()
return PublicAssetAnalyticsResponse(
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
@router.get("/{slug}/pdf")
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
version: CvVersion | None = None
if asset.version_id:
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
version = (await session.execute(stmt)).scalars().one_or_none()
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
blocks = version.structured_blocks or [] if version else []
patched = generate_patched_docx(docx_bytes, blocks)
pdf_bytes = docx_bytes_to_pdf(patched)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
base = settings.public_base_url.rstrip("/")
url = f"{base}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,

View File

@@ -7,6 +7,7 @@ from app.api.deps import get_current_user, get_db
from app.schemas import BranchCreateRequest, VersionResponse
from app.services.versions import create_branch, delete_version
from dlib.auth import AuthenticatedUser
from dlib.cv.ats_guard import PatchValidationError
router = APIRouter(prefix="/versions", tags=["versions"])
@@ -18,14 +19,17 @@ async def create_version_branch(
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
try:
version = await create_branch(
session,
owner_id=user.sub,
parent_version_id=payload.parent_version_id,
branch_name=payload.branch_name,
version_label=payload.version_label,
patches=payload.patches,
)
except PatchValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if not version:
raise HTTPException(status_code=404, detail="Parent version not found")
return VersionResponse.model_validate(version)

View File

@@ -57,6 +57,13 @@ class Settings(BaseSettings):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@field_validator("storage_endpoint_url", mode="before")
@classmethod
def _empty_endpoint_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

@@ -4,6 +4,7 @@ from .cv import (
CvPatch,
CvVersion,
PublicAsset,
PublicAssetView,
Specialization,
Submission,
SubmissionStatus,
@@ -17,5 +18,6 @@ __all__ = [
"Submission",
"SubmissionStatus",
"PublicAsset",
"PublicAssetView",
"AiSuggestion",
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
@@ -21,7 +22,11 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
)
versions: Mapped[list["CvVersion"]] = relationship(
"CvVersion", back_populates="document", foreign_keys="[CvVersion.document_id]"
"CvVersion",
back_populates="document",
foreign_keys="[CvVersion.document_id]",
cascade="all, delete-orphan",
passive_deletes=True,
)
@@ -134,6 +139,24 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
"Submission", back_populates="public_asset"
)
version: Mapped[CvVersion | None] = relationship("CvVersion")
views: Mapped[list["PublicAssetView"]] = relationship(
"PublicAssetView", back_populates="public_asset", cascade="all, delete-orphan", passive_deletes=True,
)
class PublicAssetView(Base, IdentifierMixin):
__tablename__ = "public_asset_views"
public_asset_id: Mapped[str] = mapped_column(
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
public_asset: Mapped[PublicAsset] = relationship("PublicAsset", back_populates="views")
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

@@ -4,6 +4,7 @@ from .cv import (
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
@@ -28,4 +29,5 @@ __all__ = [
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
]

View File

@@ -125,5 +125,11 @@ class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse
class PublicAssetAnalyticsResponse(BaseModel):
slug: str
view_count: int
last_viewed_at: datetime | None = None
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -23,20 +23,22 @@ async def create_document(
structured = parse_docx_bytes(file_bytes, version_label="root")
doc = CvDocument(owner_id=owner_id, title=title, description=description)
session.add(doc)
await session.flush() # persist doc so version FK is satisfied
version = CvVersion(
document=doc,
document_id=doc.id,
branch_name="root",
version_label="root",
artifact_docx_key=artifact_key,
structured_blocks=[block.model_dump() for block in structured.blocks],
metadata_json={"ingested": True},
)
doc.versions.append(version)
doc.root_version_id = version.id
session.add(version)
await session.flush() # persist version so root_version_id FK is satisfied
session.add(doc)
doc.root_version_id = version.id
await session.commit()
await session.refresh(doc)
stmt = (
select(CvDocument)

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(process.cwd(), "../.."),
async rewrites() {
const backend = process.env.API_BASE_URL ?? "https://api.cv.alves.world";
const backend = process.env.API_BASE_URL ?? "http://localhost:9812";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
};

View File

@@ -1,21 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuer = process.env.AUTHENTIK_ISSUER;
const issuerRaw = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL ?? origin}/api/auth/callback`;
const publicBase = process.env.NEXT_PUBLIC_BASE_URL ?? origin;
const redirectUri = `${publicBase}/api/auth/callback`;
if (!issuer || !clientId || !clientSecret) {
return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`);
const authentikHost = authentikBase(issuerRaw);
if (!authentikHost || !clientId || !clientSecret) {
return NextResponse.redirect(`${publicBase}/login?error=oidc_not_configured`);
}
const tokenRes = await fetch(`${issuer}/application/o/token/`, {
const tokenRes = await fetch(`${authentikHost}/application/o/token/`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
@@ -27,7 +40,7 @@ export async function GET(req: NextRequest) {
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${origin}/dashboard`);
const res = NextResponse.redirect(`${publicBase}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,

View File

@@ -3,12 +3,5 @@ export default function DashboardLayout({
}: {
children: React.ReactNode
}) {
return (
<div>
<nav>
<h1>Dashboard</h1>
</nav>
<main>{children}</main>
</div>
)
return <>{children}</>;
}

View File

@@ -7,7 +7,9 @@ import Link from 'next/link';
import {
createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api';
@@ -166,7 +168,7 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
);
}
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) {
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (asset: PublicAsset) => void }) {
const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -175,7 +177,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError('');
try {
const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset.url ?? asset.slug);
onDone(asset);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
};
@@ -478,7 +480,8 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
@@ -492,7 +495,7 @@ export default function Dashboard() {
setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
})
.catch(e => setError(e.message))
.catch(() => setError('Failed to load documents. Make sure the backend is running.'))
.finally(() => setLoading(false));
}, []);
@@ -567,7 +570,12 @@ export default function Dashboard() {
};
const handleDeleteVersion = async (versionId: string) => {
if (!confirm('Delete this branch? This cannot be undone.')) return;
const version = selectedDoc?.versions.find(v => v.id === versionId);
const hasChildren = selectedDoc?.versions.some(v => v.parent_version_id === versionId);
const msg = hasChildren
? 'Delete this branch and all its sub-branches? This cannot be undone.'
: 'Delete this branch? This cannot be undone.';
if (!confirm(msg)) return;
try {
await deleteVersion(versionId);
const fresh = await refreshDocs();
@@ -610,7 +618,7 @@ export default function Dashboard() {
</button>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches
cvfs
</Link>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
@@ -757,14 +765,35 @@ export default function Dashboard() {
</div>
</div>
{publishedUrl && (
{publishedAsset && (
<div style={{
padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center',
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
}}>
<span style={{ color: '#166534' }}>Published:</span>
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a>
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
{publishedAnalytics !== null && (
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
</span>
)}
<button
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
>
stats
</button>
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
Share link
</a>
<span style={{ color: '#bbf7d0' }}>|</span>
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
View PDF
</a>
</div>
</div>
)}
@@ -856,7 +885,7 @@ export default function Dashboard() {
<PublishModal
version={selectedVersion}
onClose={() => setModal(null)}
onDone={url => { setPublishedUrl(url); setModal(null); }}
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
/>
)}
</div>

View File

@@ -2,8 +2,8 @@ import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Resume Branches",
description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
title: "cvfs",
description: "CV File System — manage your resume like code: branch, version, and tailor for different roles while preserving ATS formatting",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -3,18 +3,28 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
function authentikBase(url?: string | null) {
if (!url) return null;
try {
const parsed = new URL(url);
return parsed.origin.replace(/\/$/, '');
} catch {
return null;
}
}
function authentikUrl() {
const issuer = process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER;
const baseHost = authentikBase(process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER);
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!issuer || !clientId) return null;
if (!baseHost || !clientId) return null;
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile',
});
return `${issuer}/application/o/authorize/?${params}`;
return `${baseHost}/application/o/authorize/?${params}`;
}
export default function LoginPage() {
@@ -52,7 +62,7 @@ export default function LoginPage() {
{/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
Resume Branches
cvfs
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
@@ -126,7 +136,7 @@ export default function LoginPage() {
</div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
Resume Branches private CV control plane
cvfs CV File System
</p>
</div>
</div>

View File

@@ -10,10 +10,10 @@ export default function Home() {
<section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
Resume Branches
cvfs
</p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
Git for CVs
CV File System
</h1>
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without

View File

@@ -4,9 +4,9 @@ export default function Footer() {
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8">
<div className="col-span-1">
<h3 className="text-xl font-bold text-white mb-4">Resume Branches</h3>
<h3 className="text-xl font-bold text-white mb-4">cvfs</h3>
<p className="text-sm mb-4">
Git for CVs. Manage your resume like code with version control,
CV File System. Manage your resume like code with version control,
branching, and smart AI-assisted tailoring.
</p>
</div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between">
<p className="text-sm">© 2024 Resume Branches. All rights reserved.</p>
<p className="text-sm">© 2024 cvfs. All rights reserved.</p>
<div className="flex items-center space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
return (
<header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
<Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
Resume Branches
cvfs
</Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

@@ -94,7 +94,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
</span>
)}
{!isRoot && isLeaf && onDelete && hovered && (
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
title="Delete branch"

View File

@@ -73,6 +73,12 @@ export type PublicAsset = {
created_at: string;
};
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
@@ -86,6 +92,11 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
});
if (!res.ok) {
if (res.status === 401 && typeof window !== 'undefined') {
document.cookie = 'oidc_token_pub=; max-age=0; path=/';
document.cookie = 'oidc_token=; max-age=0; path=/';
window.location.href = '/login';
}
const detail = await res.text().catch(() => res.statusText);
throw new Error(detail || `HTTP ${res.status}`);
}
@@ -178,6 +189,12 @@ export async function publishVersion(
});
}
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import time
from functools import cached_property
from typing import Any
import httpx
@@ -21,6 +20,16 @@ class TokenValidationError(Exception):
pass
def _normalize_issuer(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
raw = value.strip().rstrip("/")
normalized = raw.replace("/application/o/authorize/", "/application/o/")
normalized = normalized.replace("/application/o/authorize", "/application/o")
normalized = normalized.rstrip("/")
return raw, normalized if normalized != raw else raw
class OidcTokenValidator:
def __init__(
self,
@@ -30,12 +39,16 @@ class OidcTokenValidator:
jwks_url: str | None = None,
disable: bool = False,
) -> None:
self.issuer = issuer
raw_issuer, discovery_issuer = _normalize_issuer(issuer)
self.issuer = raw_issuer
self.audience = audience
self.jwks_url = jwks_url or (
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None
self.jwks_url = jwks_url
self.discovery_url = (
f"{(discovery_issuer or raw_issuer).rstrip('/')}/.well-known/openid-configuration"
if (discovery_issuer or raw_issuer)
else None
)
self.disable = disable or not issuer
self.disable = disable or not raw_issuer
self._jwks: dict[str, Any] | None = None
self._jwks_expiry: float = 0
@@ -45,17 +58,22 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer"
)
header = jwt.get_unverified_header(token)
key = await self._get_key(header.get("kid"))
if not key:
raise TokenValidationError("Unable to resolve signing key")
alg = header.get("alg") or "RS256"
jwks = await self._get_jwks()
if not jwks:
raise TokenValidationError("Unable to resolve signing keys")
try:
claims = jwt.decode(
token,
key,
algorithms=[key.get("alg", "RS256")],
audience=self.audience,
issuer=self.issuer,
jwks,
algorithms=[alg],
options={"verify_aud": False, "verify_iss": False},
)
iss = claims.get("iss")
if self.issuer and iss not in (self.issuer, self.issuer + "/"):
# fallback: check if it matches discovery host
if not (iss and iss.startswith(self.issuer.split("/application/")[0])):
raise TokenValidationError(f"Invalid issuer: {iss}")
except JWTError as exc:
raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -69,7 +87,19 @@ class OidcTokenValidator:
roles=roles,
)
async def _get_key(self, kid: str | None) -> dict[str, Any] | None:
async def _ensure_jwks_url(self) -> None:
if self.jwks_url or not self.discovery_url:
return
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(self.discovery_url)
response.raise_for_status()
data = response.json()
jwks_uri = data.get("jwks_uri")
if isinstance(jwks_uri, str):
self.jwks_url = jwks_uri
async def _get_jwks(self) -> dict[str, Any] | None:
await self._ensure_jwks_url()
if not self.jwks_url:
return None
if not self._jwks or time.time() > self._jwks_expiry:
@@ -78,12 +108,7 @@ class OidcTokenValidator:
response.raise_for_status()
self._jwks = response.json()
self._jwks_expiry = time.time() + 3600
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else []
if kid:
for key in keys:
if key.get("kid") == kid:
return key
return keys[0] if keys else None
return self._jwks
def build_validator(

View File

@@ -9,6 +9,7 @@ from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset
from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [
"StructuredBlock",
@@ -21,4 +22,5 @@ __all__ = [
"apply_patchset",
"validate_patchset",
"generate_patched_docx",
"docx_bytes_to_pdf",
]

79
dlib/cv/pdf_export.py Normal file
View File

@@ -0,0 +1,79 @@
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"),
),
}
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()

View File

@@ -11,8 +11,18 @@ services:
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: ${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
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=${NEXT_PUBLIC_BASE_URL:-https://cv.alves.world}
networks:
- dokploy-network
- cvfs-network
@@ -43,6 +53,9 @@ services:
- 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:-false}
depends_on:
- postgres
- minio
@@ -136,4 +149,4 @@ services:
volumes:
redis_data:
postgres_data:
minio_data:
minio_data:

View File

@@ -8,6 +8,12 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile
FROM deps AS builder
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_AUTHENTIK_ISSUER
ARG NEXT_PUBLIC_AUTHENTIK_CLIENT_ID
ENV NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL}
ENV NEXT_PUBLIC_AUTHENTIK_ISSUER=${NEXT_PUBLIC_AUTHENTIK_ISSUER}
ENV NEXT_PUBLIC_AUTHENTIK_CLIENT_ID=${NEXT_PUBLIC_AUTHENTIK_CLIENT_ID}
COPY apps/webapp ./
RUN bun run build

View File

@@ -25,6 +25,7 @@ dependencies = [
"python-multipart",
"pyyaml",
"python-jose[cryptography]",
"reportlab",
"sqlalchemy[asyncio]",
"asyncpg",
"redis",