CyberArena / app /services /certificate_service.py
Hussien Haider
H
6c9922c
Raw
History Blame Contribute Delete
27.2 kB
"""Certificate service: issuance, verification, PDF rendering."""
import base64
import io
import os
import random
import re
import tempfile
from datetime import datetime
from typing import Optional
import httpx
from fastapi import HTTPException
from fastapi.responses import Response
from app.core.constants import CERT_REQUIRED_COMPLETIONS, CERT_VERIFY_BASE_URL
from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
from app.services.supabase_service import supabase_headers
from app.services.completion_service import count_completions
# --------------------------------------------------------------------------- #
# Arabic text support (safe imports — module works without them) #
# --------------------------------------------------------------------------- #
_HAS_ARABIC = False
try:
import arabic_reshaper
from bidi.algorithm import get_display
_HAS_ARABIC = True
except ImportError:
pass
# --------------------------------------------------------------------------- #
# Arabic font helper — IBM Plex Sans Arabic (CDN download, cached) #
# --------------------------------------------------------------------------- #
_ARABIC_FONT_REGISTERED = False
_ARABIC_FONT_NAME = "IBMPlexSansArabic"
_ARABIC_FONT_BOLD_NAME = "IBMPlexSansArabic-Bold"
_FONT_CACHE = os.path.join(tempfile.gettempdir(), "ca_ibmplexarabic.ttf")
_FONT_URLS = [
"https://github.com/google/fonts/raw/main/ofl/ibmplexsansarabic/IBMPlexSansArabic-Regular.ttf",
"https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/ibmplexsansarabic/IBMPlexSansArabic-Regular.ttf",
]
_FONT_BOLD_CACHE = os.path.join(tempfile.gettempdir(), "ca_ibmplexarabic_bold.ttf")
_FONT_BOLD_URLS = [
"https://github.com/google/fonts/raw/main/ofl/ibmplexsansarabic/IBMPlexSansArabic-Bold.ttf",
"https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/ibmplexsansarabic/IBMPlexSansArabic-Bold.ttf",
]
def _download_font(cache_path, urls):
if os.path.exists(cache_path) and os.path.getsize(cache_path) > 10000:
return cache_path
for url in urls:
try:
r = httpx.get(url, timeout=20, follow_redirects=True)
r.raise_for_status()
with open(cache_path, "wb") as f:
f.write(r.content)
if os.path.getsize(cache_path) > 10000:
return cache_path
except Exception:
continue
return None
def _ensure_arabic_font():
global _ARABIC_FONT_REGISTERED
if _ARABIC_FONT_REGISTERED:
return _ARABIC_FONT_NAME, _ARABIC_FONT_BOLD_NAME
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
regular = _download_font(_FONT_CACHE, _FONT_URLS)
bold = _download_font(_FONT_BOLD_CACHE, _FONT_BOLD_URLS)
if regular and bold:
try:
pdfmetrics.registerFont(TTFont(_ARABIC_FONT_NAME, regular))
pdfmetrics.registerFont(TTFont(_ARABIC_FONT_BOLD_NAME, bold))
_ARABIC_FONT_REGISTERED = True
return _ARABIC_FONT_NAME, _ARABIC_FONT_BOLD_NAME
except Exception:
pass
return "Helvetica", "Helvetica-Bold"
def _ar(text: str) -> str:
"""Reshape + reorder Arabic text (no-op if deps missing)."""
if not text or not _HAS_ARABIC:
return text
try:
reshaped = arabic_reshaper.reshape(text)
return get_display(reshaped)
except Exception:
return text
# --------------------------------------------------------------------------- #
# Logo preparation (convert ALPHA-LOGO.png RGBA → RGB JPEG, at import time) #
# --------------------------------------------------------------------------- #
_LOGO_JPG_PATH: Optional[str] = None
try:
from PIL import Image as _PIL
from app.services.logo_data import LOGO_B64
_raw = base64.b64decode(LOGO_B64)
_pil = _PIL.open(io.BytesIO(_raw))
if _pil.mode != "RGB":
_bg = _PIL.new("RGB", _pil.size, (15, 27, 45))
if _pil.mode == "RGBA":
_bg.paste(_pil, mask=_pil.split()[3])
else:
_bg.paste(_pil)
_pil = _bg
_path = os.path.join(tempfile.gettempdir(), "ca_alpha_logo.jpg")
_pil.save(_path, format="JPEG", quality=95)
_LOGO_JPG_PATH = _path
except Exception:
_LOGO_JPG_PATH = ""
# --------------------------------------------------------------------------- #
# Shield logo (reportlab Drawing rendered onto canvas) #
# --------------------------------------------------------------------------- #
def _draw_shield(c: "canvas.Canvas", cx: float, cy: float, sz: float):
"""Draw the platform shield logo centered at (cx, cy) with size sz."""
from reportlab.lib.colors import HexColor, Color
from reportlab.graphics.shapes import Drawing, Path, Group, Rect, Circle
from reportlab.graphics import renderPDF
S = sz / 200.0
d = Drawing(sz, sz * 1.2) # original viewBox is 200×240
def ox(v): return (v - 100) * S
def oy(v): return (240 - v) * S
G = HexColor("#10b981")
D = HexColor("#0a0a0a")
L = HexColor("#1a1a1a")
g = Group()
# Outer shield path (converted Q→C for reportlab cubic bezier)
# Q 198 198 100 232 → C 192.67 174.67 165.33 209.33 100 232
# Q 18 198 18 128 → C 45.33 209.33 18 174.67 18 128
p = Path()
p.moveTo(ox(100), oy(8))
p.lineTo(ox(182), oy(48))
p.lineTo(ox(182), oy(128))
p.curveTo(ox(192.67), oy(174.67), ox(165.33), oy(209.33), ox(100), oy(232))
p.curveTo(ox(45.33), oy(209.33), ox(18), oy(174.67), ox(18), oy(128))
p.lineTo(ox(18), oy(48))
p.closePath()
p.strokeColor = G
p.strokeWidth = max(0.5, S * 3)
p.fillColor = L
g.add(p)
# Inner dashed shield
# Q 168 188 100 216 → C 165.33 181.33 145.33 201.33 100 216
# Q 32 188 32 128 → C 54.67 201.33 32 181.33 32 128
p2 = Path()
p2.moveTo(ox(100), oy(22))
p2.lineTo(ox(168), oy(56))
p2.lineTo(ox(168), oy(128))
p2.curveTo(ox(165.33), oy(181.33), ox(145.33), oy(201.33), ox(100), oy(216))
p2.curveTo(ox(54.67), oy(201.33), ox(32), oy(181.33), ox(32), oy(128))
p2.lineTo(ox(32), oy(56))
p2.closePath()
p2.strokeColor = Color(16/255, 185/255, 129/255, alpha=0.25)
p2.strokeWidth = max(0.3, S * 1)
p2.strokeDashArray = [max(1, S * 3), max(1, S * 4)]
p2.fillColor = None
g.add(p2)
# Lock body
lock = Rect(-S*56/2, oy(154), S*56, S*48, rx=S*6, ry=S*6)
lock.fillColor = G
lock.strokeColor = None
g.add(lock)
# Lock shackle (U-shape via two lines + arc)
sw = max(0.5, S * 6)
shackle = Path()
shackle.moveTo(ox(82), oy(118))
shackle.lineTo(ox(82), oy(102))
# Arc from (82,102) to (118,102) going top — approximated with cubic bezier
# Semi-circle: control points at 0.552R offset
r18 = S * 18
k = 0.552 * r18
shackle.curveTo(ox(82), oy(102) + k, ox(118), oy(102) + k, ox(118), oy(102))
shackle.lineTo(ox(118), oy(118))
shackle.strokeColor = G
shackle.strokeWidth = sw
# "round" line cap
shackle.fillColor = None
g.add(shackle)
# Keyhole circle
kh = Circle(ox(100), oy(138), S*5)
kh.fillColor = D
kh.strokeColor = None
g.add(kh)
# Keyhole rectangle below
khr = Rect(ox(100)-S*2.5, oy(144)-S*7, S*5, S*14, rx=S*1, ry=S*1)
khr.fillColor = D
khr.strokeColor = None
g.add(khr)
d.add(g)
renderPDF.draw(d, c, cx - sz/2, cy - sz*1.2/2)
# --------------------------------------------------------------------------- #
# Verifiability #
# --------------------------------------------------------------------------- #
def make_verify_code(user_id: str) -> str:
"""Generate a short, human-friendly verify code (8-10 chars)."""
alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # no 0/1/I/O confusion
suffix = "".join(random.choice(alphabet) for _ in range(4))
head = re.sub(r"[^a-f0-9]", "", (user_id or "").lower())[:6].upper()
if len(head) < 4:
head = (head + "USER1")[:6]
return f"APEX-{head}-{suffix}"
def build_cert_title(category: str) -> str:
cat = (category or "").strip().lower()
mapping = {
"vulnerability-hunter": "Cybersecurity Vulnerability Hunter",
"code-fixing": "Secure Code Specialist",
"log-analysis": "SOC Log Analysis Operator",
"web": "Web Application Security",
"crypto": "Applied Cryptography",
}
return mapping.get(cat, category or "Cybersecurity")
def category_label(category: str, lang: str = "en") -> str:
cat = (category or "").strip().lower()
ar_map = {
"vulnerability-hunter": "صياد الثغرات",
"code-fixing": "إصلاح الأكواد",
"log-analysis": "تحليل السجلات",
"web": "أمن الويب",
"crypto": "التشفير",
}
en_map = {
"vulnerability-hunter": "Vulnerability Hunter",
"code-fixing": "Code Fixing",
"log-analysis": "Log Analysis",
"web": "Web Security",
"crypto": "Cryptography",
}
return (ar_map if lang == "ar" else en_map).get(cat, category or "")
def _format_date_en(dt_value) -> str:
if not dt_value:
return ""
try:
if isinstance(dt_value, str):
s = dt_value.replace("Z", "+00:00")
dt = datetime.fromisoformat(s)
else:
dt = dt_value
return dt.strftime("%B %d, %Y")
except Exception:
return str(dt_value)[:10]
# --------------------------------------------------------------------------- #
# PDF generation #
# --------------------------------------------------------------------------- #
def build_certificate_pdf(
user_name: str,
category: str,
title: str,
issue_date: str,
verify_code: str,
cert_id: str,
lang: str = "en",
) -> bytes:
from reportlab.lib.pagesizes import landscape, A4
from reportlab.lib.colors import HexColor, Color
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
import qrcode
from qrcode.image.pil import PilImage
W, H = landscape(A4)
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=landscape(A4))
# ── Landing Page color palette (cream/white, emerald, amber) ──
BG_CARD = HexColor("#FFFFFF") # white card
GREEN = HexColor("#10B981") # emerald — primary brand accent
AMBER = HexColor("#F59E0B") # amber/gold accent
TEXT_PRI = HexColor("#0A0A0A") # near black
TEXT_SEC = HexColor("#5A5A58") # medium gray
TEXT_MUTED = Color(90/255, 90/255, 88/255, alpha=0.5)
BORDER = Color(10/255, 10/255, 10/255, alpha=0.08)
ar_font, ar_font_bold = _ensure_arabic_font()
en_font = "Helvetica"
en_font_bold = "Helvetica-Bold"
_AR = re.compile(r'[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]', re.UNICODE)
def _is_ar(text):
return bool(_AR.search(str(text or "")))
def _font(style="regular"):
return en_font_bold if style == "bold" else en_font
def _font_for(text, style="regular"):
return (ar_font_bold if style == "bold" else ar_font) if _is_ar(text) else (en_font_bold if style == "bold" else en_font)
def _tx(text):
return _ar(text) if _is_ar(text) else text
# ── Background: cream gradient ──
for y in range(0, int(H), 3):
t = 1 - y / H
r = 0.953 + 0.01 * t
g = 0.945 + 0.01 * t
b = 0.925 + 0.01 * t
c.setFillColorRGB(min(r, 1), min(g, 1), min(b, 1))
c.rect(0, y, W, 3, fill=1, stroke=0)
# ── Double-border card (white outer, inner with green border) ──
MARGIN = 10*mm
c.setFillColor(BG_CARD)
c.roundRect(MARGIN, MARGIN, W - 2*MARGIN, H - 2*MARGIN, 6*mm, fill=1, stroke=0)
c.setStrokeColor(Color(16/255, 185/255, 129/255, alpha=0.15))
c.setLineWidth(0.3)
MARGIN2 = 13*mm
c.roundRect(MARGIN2, MARGIN2, W - 2*MARGIN2, H - 2*MARGIN2, 5*mm, fill=0, stroke=1)
# Corner ornaments (small L-shapes)
def _corner(x, y, flip_x=False, flip_y=False):
s = 4*mm
c.setStrokeColor(GREEN)
c.setLineWidth(0.4)
if flip_x:
x -= s
if flip_y:
y -= s
c.line(x, y, x + s, y) if not flip_x else c.line(x, y, x - s, y)
c.line(x, y, x, y + s) if not flip_y else c.line(x, y, x, y - s)
_corner(MARGIN2 + 3*mm, H - MARGIN2 - 3*mm) # top-left
_corner(W - MARGIN2 - 3*mm, H - MARGIN2 - 3*mm, flip_x=True) # top-right
_corner(MARGIN2 + 3*mm, MARGIN2 + 3*mm, flip_y=True) # bottom-left
_corner(W - MARGIN2 - 3*mm, MARGIN2 + 3*mm, flip_x=True, flip_y=True) # bottom-right
CX = W / 2
INNER_L = MARGIN2 + 10*mm
INNER_R = W - MARGIN2 - 10*mm
TOP = H - MARGIN2 - 14*mm
BOT = MARGIN2 + 12*mm
# ── Top gradient stripe (amber → emerald → amber) ──
stripe_y = TOP + 8*mm
for x in range(0, int(W - 2*MARGIN2), 2):
t2 = x / (W - 2*MARGIN2)
if t2 < 0.5:
mix = t2 * 2
r = 245/255 * (1-mix) + 16/255 * mix
g = 158/255 * (1-mix) + 185/255 * mix
b = 11/255 * (1-mix) + 129/255 * mix
else:
mix = (t2 - 0.5) * 2
r = 16/255 * (1-mix) + 245/255 * mix
g = 185/255 * (1-mix) + 158/255 * mix
b = 129/255 * (1-mix) + 11/255 * mix
c.setFillColorRGB(min(r, 1), min(g, 1), min(b, 1))
c.rect(MARGIN2 + x, stripe_y, 2, 1.5*mm, fill=1, stroke=0)
# ── Header: logos + brand left / CYBERARENA right ──
head_y = stripe_y - 4*mm
logo_size = 9 * mm
# ALPHA logo
if _LOGO_JPG_PATH and os.path.exists(_LOGO_JPG_PATH):
c.drawImage(_LOGO_JPG_PATH, INNER_L, head_y - 2*mm, width=logo_size, height=logo_size, preserveAspectRatio=True)
# Shield logo (next to ALPHA logo)
shield_sz = logo_size * 0.85
_draw_shield(c, INNER_L + logo_size + shield_sz/2 + 2*mm, head_y + shield_sz/2, shield_sz)
text_x = INNER_L + logo_size + shield_sz + 5*mm
c.setFillColor(TEXT_PRI)
c.setFont(_font("bold"), 9)
c.drawString(text_x, head_y + 2*mm, _tx("ALPHA TEAM"))
c.setFillColor(TEXT_MUTED)
c.setFont(_font(), 6)
c.drawString(text_x, head_y - 3*mm, _tx("CYBERSECURITY"))
c.setFillColor(GREEN)
c.setFont(_font("bold"), 11)
c.drawRightString(INNER_R, head_y + 2*mm, _tx("CYBERARENA"))
c.setFillColor(TEXT_MUTED)
c.setFont(_font(), 6)
c.drawRightString(INNER_R, head_y - 3*mm, _tx("CERTIFICATE OF COMPLETION"))
# divider
div_y = head_y - 8*mm
c.setStrokeColor(Color(16/255, 185/255, 129/255, alpha=0.15))
c.setLineWidth(0.3)
c.line(INNER_L, div_y, INNER_R + 2*mm, div_y)
# ── Body ──
# Label "CERTIFICATE" (small, muted, uppercase)
c.setFillColor(TEXT_MUTED)
c.setFont(_font("bold"), 7)
c.drawCentredString(CX, div_y - 8*mm, "CERTIFICATE")
# Intro
c.setFillColor(TEXT_SEC)
c.setFont(_font(), 9)
c.drawCentredString(CX, div_y - 15*mm, "This is to certify that")
# Name (hero)
name = (user_name or "Trainee")[:60]
c.setFillColor(TEXT_PRI)
c.setFont(_font_for(name, "bold"), 26)
c.drawCentredString(CX, div_y - 28*mm, _tx(name))
# Name bar: amber dot + green line + amber dot
nw = c.stringWidth(_tx(name), _font_for(name, "bold"), 26)
bar_top = div_y - 32*mm
c.setFillColor(AMBER)
c.circle(CX - nw/2 - 5, bar_top + 2.5, 1.2, fill=1, stroke=0)
c.setStrokeColor(Color(16/255, 185/255, 129/255, alpha=0.25))
c.setLineWidth(0.3)
c.line(CX - nw/2 - 3, bar_top + 1.5, CX + nw/2 + 3, bar_top + 1.5)
c.setFillColor(AMBER)
c.circle(CX + nw/2 + 5, bar_top + 2.5, 1.2, fill=1, stroke=0)
# Body text
c.setFillColor(TEXT_SEC)
c.setFont(_font(), 8)
c.drawCentredString(CX, div_y - 40*mm, "has successfully completed all interactive challenges and practical labs in")
# Category pill
cat_label = category_label(category, lang="en")
bw = c.stringWidth(_tx(cat_label), _font("bold"), 14) + 12*mm
c.setFillColor(Color(16/255, 185/255, 129/255, alpha=0.07))
c.roundRect(CX - bw/2, div_y - 50*mm - 3*mm, bw, 9*mm, 4.5*mm, fill=1, stroke=0)
c.setStrokeColor(Color(16/255, 185/255, 129/255, alpha=0.2))
c.setLineWidth(0.3)
c.roundRect(CX - bw/2, div_y - 50*mm - 3*mm, bw, 9*mm, 4.5*mm, fill=0, stroke=1)
c.setFillColor(GREEN)
c.setFont(_font("bold"), 14)
c.drawCentredString(CX, div_y - 50*mm, _tx(cat_label))
# Subtitle
c.setFillColor(TEXT_MUTED)
c.setFont(_font_for(title), 6.5)
c.drawCentredString(CX, div_y - 55*mm, _tx(title))
# ── Footer (three-column grid: QR | info | signature) ──
foot_top = BOT + 28*mm
c.setStrokeColor(Color(10/255, 10/255, 10/255, alpha=0.05))
c.setLineWidth(0.5)
c.line(INNER_L, foot_top, INNER_R, foot_top)
# Column 1: QR
qr_payload = (CERT_VERIFY_BASE_URL or "https://cyberarena.app/verify") + "/verify?code=" + verify_code
qr = qrcode.QRCode(box_size=3, border=0)
qr.add_data(qr_payload)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="#10B981", back_color="#FFFFFF", image_factory=PilImage)
qr_path = os.path.join(tempfile.gettempdir(), f"ca_qr_{verify_code.replace(' ', '_')}.png")
qr_img.save(qr_path, format="PNG")
qr_s = 14*mm
qr_x = INNER_L + 2*mm
c.drawImage(qr_path, qr_x, foot_top - qr_s - 8*mm, width=qr_s, height=qr_s, preserveAspectRatio=True, mask="auto")
c.setStrokeColor(Color(10/255, 10/255, 10/255, alpha=0.07))
c.setLineWidth(0.3)
c.roundRect(qr_x - 0.8*mm, foot_top - qr_s - 8.8*mm, qr_s + 1.6*mm, qr_s + 1.6*mm, 1.5*mm, fill=0, stroke=1)
c.setFillColor(TEXT_SEC)
c.setFont(_font_for("SCAN TO VERIFY", "bold"), 5)
c.drawCentredString(qr_x + qr_s/2, foot_top - qr_s - 11*mm, "SCAN TO VERIFY")
c.setFillColor(TEXT_MUTED)
c.setFont(_font_for(verify_code), 4.5)
c.drawCentredString(qr_x + qr_s/2, foot_top - qr_s - 14*mm, _tx(verify_code))
# Column 2: Info
infox = INNER_L + 28*mm
date_str = _format_date_en(issue_date) or "—"
info_items = [
("ISSUE DATE", date_str, TEXT_PRI, 7),
("CERTIFICATE ID", str(cert_id)[:28], TEXT_SEC, 6.5),
("VERIFICATION CODE", verify_code, GREEN, 6.5),
]
for i, (label, val, vcolor, vsize) in enumerate(info_items):
ypos = foot_top - 3.5*mm - i * 6.5*mm
c.setFillColor(TEXT_MUTED)
c.setFont(_font_for(label, "bold"), 5)
c.drawString(infox, ypos, _tx(label))
c.setFillColor(vcolor)
vfont = "Courier" if not _is_ar(val) else _font_for(val)
c.setFont(vfont, vsize)
c.drawString(infox, ypos - 2.5*mm, _tx(val))
# Column 3: Signature
sig_x = INNER_R
c.setStrokeColor(Color(90/255, 90/255, 88/255, alpha=0.15))
c.setLineWidth(0.3)
c.line(sig_x - 18*mm, foot_top - 8*mm, sig_x, foot_top - 8*mm)
c.setFillColor(TEXT_SEC)
c.setFont(_font_for("Alpha Team Academic Board", "bold"), 5.5)
c.drawRightString(sig_x, foot_top - 11*mm, "Alpha Team Academic Board")
c.setFillColor(TEXT_MUTED)
c.setFont(_font_for("Alpha Team"), 5)
c.drawRightString(sig_x, foot_top - 14*mm, "Alpha Team")
# Bottom gradient stripe
for x in range(0, int(W), 2):
t2 = x / W
if t2 < 0.5:
mix = t2 * 2
r = 245/255 * (1-mix) + 16/255 * mix
g = 158/255 * (1-mix) + 185/255 * mix
b = 11/255 * (1-mix) + 129/255 * mix
else:
mix = (t2 - 0.5) * 2
r = 16/255 * (1-mix) + 245/255 * mix
g = 185/255 * (1-mix) + 158/255 * mix
b = 129/255 * (1-mix) + 11/255 * mix
c.setFillColorRGB(min(r, 1), min(g, 1), min(b, 1))
c.rect(x, 0, 2, 0.8*mm, fill=1, stroke=0)
c.showPage()
c.save()
return buf.getvalue()
# --------------------------------------------------------------------------- #
# HTTP handlers (called by the api/certificates router) #
# --------------------------------------------------------------------------- #
async def handle_certificates(req) -> dict:
headers = {
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {SUPABASE_ANON_KEY}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
if req.action == "list":
url = f"{SUPABASE_URL}/rest/v1/certificates?select=*"
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers=headers)
if resp.status_code != 200:
raise HTTPException(status_code=resp.status_code, detail="Failed to fetch certificates")
certs = resp.json()
user_certs = [c for c in certs if str(c.get("user_id")) == str(req.user_id)]
return {"certificates": user_certs}
if req.action == "issue":
async with httpx.AsyncClient() as client:
# 1) already-issued?
check_url = (
f"{SUPABASE_URL}/rest/v1/certificates?user_id=eq.{req.user_id}"
f"&category=eq.{req.category}&select=*&limit=1"
)
check_resp = await client.get(check_url, headers=headers)
if check_resp.status_code == 200 and check_resp.json():
return {"status": "already_issued", "certificate": check_resp.json()[0]}
# 2) Eligibility
completions = await count_completions(req.user_id, req.category or "")
if completions < CERT_REQUIRED_COMPLETIONS:
return {
"status": "not_eligible",
"error": "completion_threshold_not_met",
"message": (
f"You need {CERT_REQUIRED_COMPLETIONS} completed challenges "
f"in {req.category} to earn this certificate "
f"(you currently have {completions})."
),
"completions": completions,
"required": CERT_REQUIRED_COMPLETIONS,
}
# 3) User name
user_name = ""
try:
users_resp = await client.get(
f"{SUPABASE_URL}/rest/v1/users?id=eq.{req.user_id}&select=name&limit=1",
headers=headers,
)
if users_resp.status_code == 200 and users_resp.json():
user_name = (users_resp.json()[0].get("name") or "").strip()
except Exception:
pass
# 4) Issue
verify_code = req.verify_code or make_verify_code(req.user_id)
category = req.category or "general"
payload = {
"user_id": req.user_id,
"user_name": user_name,
"category": category,
"title": (
req.details.get("title")
if isinstance(req.details, dict) and req.details.get("title")
else build_cert_title(category)
),
"verify_code": verify_code,
"issue_date": datetime.utcnow().isoformat() + "Z",
"details": req.details or {"issue_reason": "50 challenges completed"},
}
resp = await client.post(
f"{SUPABASE_URL}/rest/v1/certificates",
headers={**headers, "Prefer": "return=representation"},
json=payload,
)
if resp.status_code not in (200, 201):
raise HTTPException(
status_code=resp.status_code,
detail=f"Failed to issue certificate: {resp.text[:200]}",
)
certs = resp.json()
return {"status": "issued", "certificate": certs[0] if certs else {}}
raise HTTPException(status_code=400, detail="Invalid action")
async def download_certificate_pdf(cert_id: str, lang: str = "en") -> Response:
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
raise HTTPException(status_code=503, detail="Supabase not configured")
url = f"{SUPABASE_URL}/rest/v1/certificates?id=eq.{cert_id}&select=*&limit=1"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, headers=supabase_headers())
if r.status_code != 200:
raise HTTPException(status_code=502, detail=f"Supabase error: {r.text[:200]}")
rows = r.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
if not rows:
raise HTTPException(status_code=404, detail="الشهادة غير موجودة / Certificate not found")
cert = rows[0]
pdf_bytes = build_certificate_pdf(
user_name=cert.get("user_name") or "",
category=cert.get("category") or "",
title=cert.get("title") or build_cert_title(cert.get("category", "")),
issue_date=cert.get("issue_date") or cert.get("issued_at") or "",
verify_code=cert.get("verify_code") or "",
cert_id=cert.get("id") or cert_id,
lang="en",
)
cat = (cert.get("category") or "CyberArena").replace(" ", "_")
fname = f"CyberArena-Certificate-{cat}-{cert.get('verify_code', cert_id)[:20]}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{fname}"',
"Cache-Control": "no-store",
},
)
async def verify_certificate(verify_code: str) -> dict:
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
raise HTTPException(status_code=503, detail="Supabase not configured")
url = f"{SUPABASE_URL}/rest/v1/certificates?verify_code=eq.{verify_code}&select=*&limit=1"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(url, headers=supabase_headers())
if r.status_code != 200:
raise HTTPException(status_code=502, detail=f"Supabase error: {r.text[:200]}")
rows = r.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
if not rows:
return {"valid": False, "verify_code": verify_code, "message": "Certificate not found"}
c = rows[0]
return {
"valid": True,
"verify_code": c.get("verify_code"),
"certificate_id": c.get("id"),
"user_name": c.get("user_name"),
"category": c.get("category"),
"title": c.get("title") or build_cert_title(c.get("category", "")),
"issue_date": c.get("issue_date") or c.get("issued_at"),
}
async def cert_progress(user_id: str, category: str) -> dict:
n = await count_completions(user_id, category)
return {
"category": category,
"completions": n,
"required": CERT_REQUIRED_COMPLETIONS,
"ready": n >= CERT_REQUIRED_COMPLETIONS,
}