Compare commits

..

28 Commits

Author SHA1 Message Date
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
22 changed files with 383 additions and 77 deletions

View File

@@ -1,20 +1,53 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException import hashlib
from sqlalchemy import select 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 sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings from app.core.config import get_settings
from app.models import PublicAsset from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.services.publication import publish_version from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"]) 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) @router.post("/publish", response_model=PublicAssetResponse)
async def publish( async def publish(
payload: PublishRequest, payload: PublishRequest,
@@ -33,21 +66,78 @@ async def publish(
return _response_from_asset(asset) return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse) @router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)): async def get_analytics(
stmt = select(PublicAsset).where( slug: str,
PublicAsset.slug == slug, PublicAsset.is_public.is_(True) 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)
) )
result = await session.execute(stmt) if not (await session.execute(stmt)).scalars().one_or_none():
asset = result.scalars().one_or_none() raise HTTPException(status_code=403, detail="Not authorized")
if not asset: else:
raise HTTPException(status_code=404, detail="Asset not found") 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
)
@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)) return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse: def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings() 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( return PublicAssetResponse(
id=asset.id, id=asset.id,
slug=asset.slug, slug=asset.slug,

View File

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

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
@@ -21,7 +22,11 @@ class CvDocument(Base, IdentifierMixin, TimestampMixin):
) )
versions: Mapped[list["CvVersion"]] = relationship( 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" "Submission", back_populates="public_asset"
) )
version: Mapped[CvVersion | None] = relationship("CvVersion") 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): class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
outputFileTracingRoot: path.join(process.cwd(), "../.."), outputFileTracingRoot: path.join(process.cwd(), "../.."),
async rewrites() { 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*` }]; return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
}, },
}; };

View File

@@ -1,21 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'; 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) { export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url); const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code'); const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_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 clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; 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) { const authentikHost = authentikBase(issuerRaw);
return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`);
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', method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' }, headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ body: new URLSearchParams({
@@ -27,7 +40,7 @@ export async function GET(req: NextRequest) {
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`); if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json(); 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, { res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/', httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600, maxAge: tokens.expires_in ?? 3600,

View File

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

View File

@@ -7,7 +7,9 @@ import Link from 'next/link';
import { import {
createBranch, createSubmission, deleteDocument, deleteVersion, createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl, Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions, fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version, Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api'; } 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 [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -175,7 +177,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError(''); setLoading(true); setError('');
try { try {
const asset = await publishVersion(version.id, null, slug.trim() || null); 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); } } 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 [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null); 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 [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]); const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false); const [subsLoading, setSubsLoading] = useState(false);
@@ -492,7 +495,7 @@ export default function Dashboard() {
setDocs(d); setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } 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)); .finally(() => setLoading(false));
}, []); }, []);
@@ -567,7 +570,12 @@ export default function Dashboard() {
}; };
const handleDeleteVersion = async (versionId: string) => { 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 { try {
await deleteVersion(versionId); await deleteVersion(versionId);
const fresh = await refreshDocs(); const fresh = await refreshDocs();
@@ -610,7 +618,7 @@ export default function Dashboard() {
</button> </button>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}> <Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches cvfs
</Link> </Link>
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
@@ -757,14 +765,35 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{publishedUrl && ( {publishedAsset && (
<div style={{ <div style={{
padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center', borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
}}> }}>
<span style={{ color: '#166534' }}>Published:</span> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a> <span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button> {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> </div>
)} )}
@@ -856,7 +885,7 @@ export default function Dashboard() {
<PublishModal <PublishModal
version={selectedVersion} version={selectedVersion}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onDone={url => { setPublishedUrl(url); setModal(null); }} onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
/> />
)} )}
</div> </div>

View File

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

View File

@@ -3,18 +3,28 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; 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() { 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 clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : ''); 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({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: clientId, client_id: clientId,
redirect_uri: `${base}/api/auth/callback`, redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile', scope: 'openid email profile',
}); });
return `${issuer}/application/o/authorize/?${params}`; return `${baseHost}/application/o/authorize/?${params}`;
} }
export default function LoginPage() { export default function LoginPage() {
@@ -52,7 +62,7 @@ export default function LoginPage() {
{/* brand */} {/* brand */}
<div style={{ textAlign: 'center', marginBottom: 32 }}> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}> <div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
Resume Branches cvfs
</div> </div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}> <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account Sign in to your account
@@ -126,7 +136,7 @@ export default function LoginPage() {
</div> </div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}> <p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
Resume Branches private CV control plane cvfs CV File System
</p> </p>
</div> </div>
</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)" }}> <section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<div style={{ maxWidth: 560, margin: "0 auto" }}> <div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}> <p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
Resume Branches cvfs
</p> </p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}> <h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
Git for CVs CV File System
</h1> </h1>
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}> <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 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="max-w-7xl mx-auto px-6 py-12">
<div className="grid md:grid-cols-4 gap-8"> <div className="grid md:grid-cols-4 gap-8">
<div className="col-span-1"> <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"> <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. branching, and smart AI-assisted tailoring.
</p> </p>
</div> </div>
@@ -40,7 +40,7 @@ export default function Footer() {
</div> </div>
<div className="border-t border-gray-800 mt-8 pt-8 flex flex-col md:flex-row items-center justify-between"> <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"> <div className="flex items-center space-x-6 mt-4 md:mt-0">
<a href="#" className="text-gray-400 hover:text-white transition-colors"> <a href="#" className="text-gray-400 hover:text-white transition-colors">
<span className="sr-only">Twitter</span> <span className="sr-only">Twitter</span>

View File

@@ -4,7 +4,7 @@ export default function Header() {
return ( 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 }}> <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" }}> <Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
Resume Branches cvfs
</Link> </Link>
<nav style={{ display: "flex", alignItems: "center", gap: 24 }}> <nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => ( {[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (

View File

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

View File

@@ -73,6 +73,12 @@ export type PublicAsset = {
created_at: string; 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) // reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> { function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {}; if (typeof document === 'undefined') return {};
@@ -178,6 +184,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> { export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, { const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE', method: 'DELETE',

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import time import time
from functools import cached_property
from typing import Any from typing import Any
import httpx import httpx
@@ -21,6 +20,16 @@ class TokenValidationError(Exception):
pass 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: class OidcTokenValidator:
def __init__( def __init__(
self, self,
@@ -30,12 +39,16 @@ class OidcTokenValidator:
jwks_url: str | None = None, jwks_url: str | None = None,
disable: bool = False, disable: bool = False,
) -> None: ) -> None:
self.issuer = issuer raw_issuer, discovery_issuer = _normalize_issuer(issuer)
self.issuer = raw_issuer
self.audience = audience self.audience = audience
self.jwks_url = jwks_url or ( self.jwks_url = jwks_url
f"{issuer.rstrip('/')}/.well-known/jwks.json" if issuer else None 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: dict[str, Any] | None = None
self._jwks_expiry: float = 0 self._jwks_expiry: float = 0
@@ -45,17 +58,22 @@ class OidcTokenValidator:
sub="dev-user", email="dev@example.com", name="Developer" sub="dev-user", email="dev@example.com", name="Developer"
) )
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
key = await self._get_key(header.get("kid")) alg = header.get("alg") or "RS256"
if not key: jwks = await self._get_jwks()
raise TokenValidationError("Unable to resolve signing key") if not jwks:
raise TokenValidationError("Unable to resolve signing keys")
try: try:
claims = jwt.decode( claims = jwt.decode(
token, token,
key, jwks,
algorithms=[key.get("alg", "RS256")], algorithms=[alg],
audience=self.audience, options={"verify_aud": False, "verify_iss": False},
issuer=self.issuer,
) )
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: except JWTError as exc:
raise TokenValidationError(str(exc)) from exc raise TokenValidationError(str(exc)) from exc
roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or [] roles = claims.get("roles") or claims.get("app_metadata", {}).get("roles") or []
@@ -69,7 +87,19 @@ class OidcTokenValidator:
roles=roles, 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: if not self.jwks_url:
return None return None
if not self._jwks or time.time() > self._jwks_expiry: if not self._jwks or time.time() > self._jwks_expiry:
@@ -78,12 +108,7 @@ class OidcTokenValidator:
response.raise_for_status() response.raise_for_status()
self._jwks = response.json() self._jwks = response.json()
self._jwks_expiry = time.time() + 3600 self._jwks_expiry = time.time() + 3600
keys = self._jwks.get("keys", []) if isinstance(self._jwks, dict) else [] return self._jwks
if kid:
for key in keys:
if key.get("kid") == kid:
return key
return keys[0] if keys else None
def build_validator( def build_validator(

View File

@@ -9,6 +9,7 @@ from .parser import parse_docx_bytes, summarize_keywords
from .patcher import apply_patchset from .patcher import apply_patchset
from .ats_guard import validate_patchset from .ats_guard import validate_patchset
from .docx_export import generate_patched_docx from .docx_export import generate_patched_docx
from .pdf_export import docx_bytes_to_pdf
__all__ = [ __all__ = [
"StructuredBlock", "StructuredBlock",
@@ -21,4 +22,5 @@ __all__ = [
"apply_patchset", "apply_patchset",
"validate_patchset", "validate_patchset",
"generate_patched_docx", "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: build:
context: ./ context: ./
dockerfile: ./docker/webapp.Dockerfile 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: environment:
- API_BASE_URL=http://cvfs-backend:8080 - 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: networks:
- dokploy-network - dokploy-network
- cvfs-network - cvfs-network
@@ -43,6 +53,9 @@ services:
- 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_AUDIENCE=${AUTH_OIDC_AUDIENCE}
- AUTH_DISABLE_VERIFICATION=${AUTH_DISABLE_VERIFICATION:-false}
depends_on: depends_on:
- postgres - postgres
- minio - minio

View File

@@ -8,6 +8,12 @@ COPY apps/webapp/package.json apps/webapp/bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
FROM deps AS builder 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 ./ COPY apps/webapp ./
RUN bun run build RUN bun run build

View File

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