Spaces:
Sleeping
Sleeping
| """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, | |
| } | |