Compare commits

..

22 Commits

Author SHA1 Message Date
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
15 changed files with 362 additions and 54 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)
)
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: @router.get("/{slug}/pdf")
raise HTTPException(status_code=404, detail="Asset not found") 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

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

@@ -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);
@@ -762,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>
)} )}
@@ -861,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

@@ -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() {

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",