diff --git a/.gitignore b/.gitignore index 359c40a..54d6957 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ .idea/* *.history/* .idea -node_modules \ No newline at end of file +node_modules +generated_certificates/* +.secret \ No newline at end of file diff --git a/app/c_certificate_service.py b/app/c_certificate_service.py new file mode 100644 index 0000000..1f381c9 --- /dev/null +++ b/app/c_certificate_service.py @@ -0,0 +1,257 @@ +import os, json, hmac, hashlib, uuid, datetime, re +from app.utility.base_object import BaseObject + +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib.utils import ImageReader +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.lib.pagesizes import letter, landscape +from reportlab.lib.colors import Color + +# Logos +LOGO_TOP_CENTER = 'plugins/training/static/templates/mitreCaldera.png' +LOGO_BOTTOM_LEFT = 'plugins/training/static/templates/caldera_logo.png' + +# Colors to match the screenshot +WHITE = Color(1, 1, 1) +GREY = Color(0.85, 0.85, 0.85) +RED = Color(0.86, 0.0, 0.0) # Caldera-ish red + + + +SECRET_FILE = 'plugins/training/.secret' +OUT_DIR = 'plugins/training/generated_certificates' +INDEX_FILE = os.path.join(OUT_DIR, 'issued.json') +TEMPLATE_BG_PNG = 'plugins/training/static/templates/caldera_cert.png' + + +def _ensure_dirs(): + os.makedirs(OUT_DIR, exist_ok=True) + os.makedirs(os.path.dirname(TEMPLATE_BG_PNG), exist_ok=True) + +def _load_or_create_secret(): + _ensure_dirs() + if os.path.exists(SECRET_FILE): + return open(SECRET_FILE, 'rb').read() + s = uuid.uuid4().bytes + with open(SECRET_FILE, 'wb') as f: + f.write(s) + return s + +def _load_index(): + _ensure_dirs() + if os.path.exists(INDEX_FILE): + with open(INDEX_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + return {} + +def _save_index(idx): + with open(INDEX_FILE, 'w', encoding='utf-8') as f: + json.dump(idx, f, indent=2) + +class CertificateService(BaseObject): + def __init__(self): + super().__init__() + self.secret = _load_or_create_secret() + + def _key(self, instance_id, user_id, cert_id): + return f'{instance_id}:{user_id}:{cert_id}' + + def already_issued(self, instance_id, user_id, cert_id): + idx = _load_index() + return self._key(instance_id, user_id, cert_id) in idx + + def mark_issued(self, instance_id, user_id, cert_id, path, name): + idx = _load_index() + idx[self._key(instance_id, user_id, cert_id)] = { + 'path': path, + 'name': name, + 'issued_at': datetime.datetime.utcnow().isoformat()+'Z' + } + _save_index(idx) + + def clear_issued_for_user_cert(self, instance_id, user_id, cert_id): + idx = _load_index() + idx.pop(self._key(instance_id, user_id, cert_id), None) + _save_index(idx) + + def signed_token(self, payload: str) -> str: + sig = hmac.new(self.secret, payload.encode(), hashlib.sha256).hexdigest() + return f'{payload}.{sig}' + + def verify_token(self, token: str) -> str: + try: + payload, sig = token.rsplit('.', 1) + except ValueError: + return None + exp_sig = hmac.new(self.secret, payload.encode(), hashlib.sha256).hexdigest() + return payload if hmac.compare_digest(sig, exp_sig) else None + + def _safe_part(self, s: str) -> str: + """Filesystem-safe chunk for filenames.""" + s = s.strip() + s = re.sub(r'\s+', '_', s) # spaces -> underscores + s = re.sub(r'[^A-Za-z0-9._-]', '', s) # drop weird chars + return s or 'User' + + def _draw_image_keep_aspect(self, c, path, x, y, target_w=None, target_h=None): + """Draw image at (x,y) with either target_w or target_h, preserving aspect. + (x,y) is the lower-left corner (ReportLab coordinates).""" + if not os.path.exists(path): + return + img = ImageReader(path) + iw, ih = img.getSize() + if target_w and not target_h: + scale = target_w / float(iw) + w, h = target_w, ih * scale + elif target_h and not target_w: + scale = target_h / float(ih) + w, h = iw * scale, target_h + elif target_w and target_h: + # fit inside the box + scale = min(target_w/iw, target_h/ih) + w, h = iw*scale, ih*scale + else: + w, h = iw, ih + c.drawImage(img, x, y, width=w, height=h, mask='auto') + + def _draw_centered(self, canvas, text, y, font_name, font_size, fill_rgb): + from reportlab.lib.colors import Color + c = canvas + c.setFont(font_name, font_size) + c.setFillColor(Color(*fill_rgb)) + page_w, _ = c._pagesize + c.drawCentredString(page_w/2.0, y, text) + + def _build_pdf(self, out_pdf: str, learner_name: str, cert_title: str, date_str: str): + # Fonts (keep existing register fallback) + FONT_H1 = 'Helvetica-Bold' + FONT_H2 = 'Helvetica' + FONT_NAME = 'Helvetica-Bold' + + # Landscape letter + page_w, page_h = landscape(letter) + c = canvas.Canvas(out_pdf, pagesize=(page_w, page_h)) + + # Background (scaled to page) + if os.path.exists(TEMPLATE_BG_PNG): + bg = ImageReader(TEMPLATE_BG_PNG) + c.drawImage(bg, 0, 0, width=page_w, height=page_h) + + # Top-center MITRE|CALDERA logo + if LOGO_TOP_CENTER and os.path.exists(LOGO_TOP_CENTER): + logo = ImageReader(LOGO_TOP_CENTER) + iw, ih = logo.getSize() + target_h = 40 + aspect = iw / ih + target_w = target_h * aspect + logo_x = (page_w - target_w) / 2 + logo_y = page_h - target_h - 60 + c.drawImage(logo, logo_x, logo_y, width=target_w, height=target_h, mask='auto') + # add comfortable space under the logo + top_y = logo_y - 10 + else: + top_y = page_h - 140 + + # Bottom-left Caldera logo (larger) + self._draw_image_keep_aspect(c, LOGO_BOTTOM_LEFT, x=24, y=28, target_h=120) + + # Title — sits just under the centered logo + c.setFillColor(WHITE) + c.setFont(FONT_H1, 46) + c.drawCentredString(page_w/2.0, top_y - 33, "CERTIFICATE OF COMPLETION") + + # “This certificate is presented to:” + c.setFillColor(GREY) + c.setFont(FONT_H2, 16) + c.drawCentredString(page_w/2.0, top_y - 70, "THIS CERTIFICATE IS PRESENTED TO:") + + # NAME (red, bold) + c.setFillColor(RED) + c.setFont(FONT_NAME, 36) + c.drawCentredString(page_w/2.0, top_y - 120, learner_name) + + # Divider line under the name + c.setStrokeColor(WHITE) + c.setLineWidth(1.5) + c.line(page_w*0.18, top_y - 136, page_w*0.82, top_y - 136) + + # “For the successful completion…” line + c.setFillColor(GREY) + c.setFont(FONT_H2, 16) + line = f"FOR THE SUCCESSFUL COMPLETION OF THE {cert_title.upper()} TRAINING PLUGIN" + c.drawCentredString(page_w/2.0, top_y - 180, line) + + # Bottom-right “CERTIFICATION DATE:” and date (aligned with bottom-left logo) + logo_bottom = 28 # same y as Caldera logo bottom + right_margin = page_w - 28 # page right padding + + # Text settings (unchanged fonts/sizes) + label = "CERTIFICATION DATE:" + label_font = FONT_H1 + label_size = 12 + date_font = FONT_H2 + date_size = 14 + line_gap = 1 # gap between the two lines + + # Measure text to size the box + label_w = c.stringWidth(label, label_font, label_size) + date_w = c.stringWidth(date_str, date_font, date_size) + max_w = max(label_w, date_w) + + # Box geometry + box_pad_x = 8 + box_pad_y = 8 + box_w = max_w + (box_pad_x * 2) + box_h = label_size + line_gap + date_size + (box_pad_y * 2) + + # Anchor the box to the page’s right padding and the bottom-left logo’s bottom + box_x = right_margin - box_w + box_y = logo_bottom + + # Draw black background box (no stroke) + c.setFillColorRGB(0, 0, 0) + c.rect(box_x, box_y, box_w, box_h, fill=1, stroke=0) + + # Baselines for the two lines inside the box + label_y = box_y + box_h - box_pad_y - 8 + date_y = label_y - (label_size + 6) + + # Draw label (right-aligned to the inner right edge of the box) + c.setFillColor(WHITE) + c.setFont(label_font, label_size) + c.drawRightString(box_x + box_w - box_pad_x, label_y, label) + + # Draw date centered under the label, inside the box + c.setFont(date_font, date_size) + center_x = box_x + (box_w / 2.0) + c.drawCentredString(center_x, date_y, date_str) + + c.showPage() + c.save() + + def issue(self, instance_id, user_id, cert_name, display_name): + """ + Build and return a PDF path. Filename format: + Caldera_User_Certificate__.pdf + """ + _ensure_dirs() + safe_name = self._safe_part(display_name) + date_tag = datetime.datetime.now().strftime('%Y%m%d') + base = f'Caldera_User_Certificate_{safe_name}_{date_tag}.pdf' + out_pdf = os.path.join(OUT_DIR, base) + + # Visible strings + display_cert_title = "MITRE Caldera™ User" + + # Render PDF + pretty_date = datetime.datetime.now().strftime('%B %d, %Y') + self._build_pdf(out_pdf, safe_name, display_cert_title, pretty_date) + + return out_pdf + + def get_record(self, instance_id, user_id, cert_id): + # Return issuance record or None. + idx = _load_index() + return idx.get(self._key(instance_id, user_id, cert_id)) diff --git a/app/training_api.py b/app/training_api.py index d2fdd79..918230f 100644 --- a/app/training_api.py +++ b/app/training_api.py @@ -1,4 +1,7 @@ import logging +import traceback + +import os, hashlib, mimetypes, base64 from aiohttp import web from aiohttp_jinja2 import template @@ -7,6 +10,7 @@ from app.service.auth_svc import for_all_public_methods, check_authorization from plugins.training.app import errors from plugins.training.app.base_flag import BaseFlag +from plugins.training.app.c_certificate_service import CertificateService @for_all_public_methods(check_authorization) @@ -16,6 +20,9 @@ def __init__(self, services): self.auth_svc = services.get('auth_svc') self.data_svc = services.get('data_svc') self.services = services + self.cert_service = CertificateService() + self.logger = logging.getLogger('training_api') + @template('training.html') async def splash(self, request): @@ -58,6 +65,10 @@ async def certificate_solution_guide(self, request): return dict(certificate=certificate_list[0]) + def _request_user_id(self, request) -> str: + # Stable per-user key: API KEY header or fallback + return request.headers.get('X-User-ID') or request.headers.get('KEY') or 'unknown' + async def retrieve_flags(self, request): data = dict(await request.json()) answers = {} @@ -104,3 +115,138 @@ async def reset_flag(self, request): except Exception as e: logging.error(e) return web.json_response(dict(reset=reset)) + + async def can_issue(self, request, cert_name): + # Compute completion using same badge/flag iteration you already use in retrieve_flags + access = dict(access=tuple(await self.auth_svc.get_permissions(request))) + certs = await self.data_svc.locate('certifications', match=access) + cert = next((c for c in certs if c.name == cert_name), None) + if not cert: + raise web.HTTPNotFound(text='Certificate does not exist') + + # Debugging bypass + if os.getenv("TRAINING_CERT_BYPASS") == "1": + return cert, True + + complete = all(f.completed for b in cert.badges for f in b.flags) + return cert, complete + + async def issue_certificate(self, request): + """ + POST /plugin/training/certificate/issue + { "certificate": "Caldera User", "name": "Jane Doe" } + """ + try: + body = await request.json() + cert_name = body.get('certificate') + display_name = body.get('name') + self.logger.debug("issue_certificate: body=%r", body) + if not cert_name or not display_name: + raise web.HTTPBadRequest(text='certificate and name required') + + user_id = request.headers.get('X-User-ID') or request.headers.get('KEY') or 'unknown' + self.logger.debug("issue_certificate: user_id=%s", user_id) + + # Create a per-instance id from secret (stable across restarts) + instance_id = hashlib.sha256(self.cert_service.secret).hexdigest()[:12] + self.logger.debug("issue_certificate: instance_id=%s", instance_id) + + cert, complete = await self.can_issue(request, cert_name) + cert_id = getattr(cert, 'unique', None) or getattr(cert, 'identifier', None) or getattr(cert, 'id', None) or cert.name + self.logger.debug("issue_certificate: cert_id=%s complete=%s", cert_id, complete) + if not complete: + raise web.HTTPForbidden(text='Training not complete') + + if self.cert_service.already_issued(instance_id, user_id, cert_id): + # Return existing (idempotent UX) + rec = self.cert_service.get_record(instance_id, user_id, cert_id) + payload = rec['path'] + token = self.cert_service.signed_token(payload) + return web.json_response({'alreadyIssued': True, 'download': f'/plugin/training/certificate/download?token={token}'}) + + # Generate PDF directly + path = self.cert_service.issue(instance_id, user_id, cert.name, display_name) + self.cert_service.mark_issued(instance_id, user_id, cert_id, path, display_name) + token = self.cert_service.signed_token(path) + return web.json_response({'alreadyIssued': False, 'download': f'/plugin/training/certificate/download?token={token}'}) + except web.HTTPException: + raise + except Exception: + logging.exception("issue_certificate failed") + raise web.HTTPInternalServerError(text='issue_certificate failed; see server logs') + + async def reset_issuance(self, request): + """ + POST /plugin/training/certificate/reset + { "certificate": "Caldera User", "user_id": "" } + Called by existing reset logic after training reset completes. + """ + body = await request.json() + cert_name = body.get('certificate') + if not cert_name: + raise web.HTTPBadRequest(text='certificate required') + user_id = body.get('user_id') or self._request_user_id(request) + instance_id = hashlib.sha256(self.cert_service.secret).hexdigest()[:12] + + # Map cert_name to unique id + certs = await self.data_svc.locate('certifications', {}) + cert = next((c for c in certs if c.name == cert_name), None) + if not cert: + raise web.HTTPNotFound(text='Certificate does not exist') + cert_id = getattr(cert, 'unique', None) or getattr(cert, 'identifier', None) or getattr(cert, 'id', None) or cert.name + self.cert_service.clear_issued_for_user_cert(instance_id, user_id, cert_id) + return web.json_response({'ok': True}) + + def _attachment_headers(self, filename: str) -> dict: + ctype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + return { + 'Content-Type': ctype, + 'Content-Disposition': f'attachment; filename="{filename}"' + } + + async def download_certificate(self, request): + token = request.query.get('token', '') + path = self.cert_service.verify_token(token) + if not path or not os.path.exists(path): + raise web.HTTPNotFound(text='Invalid or expired token') + + # pick a good filename (using existing generator logic) + filename = os.path.basename(path) + return web.FileResponse(path, headers=self._attachment_headers(filename)) + + async def issue_certificate_bytes(self, request): + """ + POST /plugin/training/certificate/issue-bytes + { "certificate": "User Certificate", "name": "Jane Doe" } + -> { "filename": "Caldera_User_Certificate_Jane_Doe_20250928.pdf", + "pdf_bytes": "" } + """ + body = await request.json() + cert_name = body.get('certificate') + display_name = body.get('name') + if not cert_name or not display_name: + raise web.HTTPBadRequest(text='certificate and name required') + + # auth-ish identity; same as issue_certificate + user_id = request.headers.get('X-User-ID') or request.headers.get('KEY') or 'unknown' + instance_id = hashlib.sha256(self.cert_service.secret).hexdigest()[:12] + + cert, complete = await self.can_issue(request, cert_name) + cert_id = getattr(cert, 'unique', None) or getattr(cert, 'identifier', None) or getattr(cert, 'id', None) or cert.name + if not complete: + raise web.HTTPForbidden(text='Training not complete') + + # generate fresh PDF (no caching; you can add idempotence if you want) + pdf_path = self.cert_service.issue(instance_id, user_id, cert.name, display_name) + + # remember issuance like before + self.cert_service.mark_issued(instance_id, user_id, cert_id, pdf_path, display_name) + + # return filename + bytes (Debrief style) + with open(pdf_path, 'rb') as f: + b64 = base64.b64encode(f.read()).decode('ascii') + + return web.json_response({ + 'filename': os.path.basename(pdf_path), + 'pdf_bytes': b64 + }) diff --git a/gui/views/training.vue b/gui/views/training.vue index 83480fd..9323c50 100644 --- a/gui/views/training.vue +++ b/gui/views/training.vue @@ -3,57 +3,89 @@ import { ref, watch, onMounted, inject, onBeforeUnmount } from "vue"; const $api = inject("$api"); const selectedCert = ref(""); -const selectedBadge = ref(""); +const selectedBadge = ref(null); const badgeList = ref([]); const visibleFlagList = ref([]); const completedFlags = ref(0); const completedBadges = ref(0); const flagList = ref([]); const completedCertificate = ref(false); -const certificateCode = ref(""); -const certificateCodeList = ref([]); const end = ref(0); const certificates = ref([ { name: "User Certificate" }, { name: "Blue Certificate" }, ]); -let updateInterval = ref(); -// Simulating Alpine's initTraining with Vue's onMounted -onBeforeUnmount(() => { - if (updateInterval) clearInterval(updateInterval); -}); +const learnerName = ref(localStorage.getItem('trainingCertName') || ''); +const showIssueModal = ref(false); + +onBeforeUnmount(() => { /* nothing now */ }); onMounted(async () => { const res = await $api.get("/plugin/training/certs"); certificates.value = res.data.certificates; - let confettiScript = document.createElement("script"); - confettiScript.setAttribute( - "src", - "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js" - ); + + const confettiScript = document.createElement("script"); + confettiScript.src = "https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"; document.head.appendChild(confettiScript); - if (updateInterval) clearInterval(updateInterval); - updateInterval = setInterval(async () => { - getTraining(); - }, "3000"); + + if (selectedCert.value) getTraining(); }); -watch(selectedCert, (newValue) => { - getTraining(); +// keep only this watcher which resets UI and fetches +watch(selectedCert, () => { + completedCertificate.value = false; + end.value = 0; + selectedBadge.value = null; + visibleFlagList.value = []; + badgeList.value = []; + flagList.value = []; + completedFlags.value = 0; + completedBadges.value = 0; + if (selectedCert.value) getTraining(); }); watch(selectedBadge, (newValue) => { updateVisibleFlags(newValue); }); +function saveNameLocally() { + const n = learnerName.value.trim(); + if (n) localStorage.setItem('trainingCertName', n); +} + +async function issueCertificate() { + const name = learnerName.value.trim(); + if (!name) return alert('Enter your full name exactly as you want it on the certificate.'); + saveNameLocally(); + + try { + const { data } = await $api.post('/plugin/training/certificate/issue-bytes', { + certificate: selectedCert.value, + name + }); + // base64 → Blob → download + const byteChars = atob(data.pdf_bytes); + const byteNums = new Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) byteNums[i] = byteChars.charCodeAt(i); + const blob = new Blob([new Uint8Array(byteNums)], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.filename || 'Caldera_Certificate.pdf'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + showIssueModal.value = false; + } catch (e) { + alert('Could not issue certificate: ' + (e?.response?.data || e)); + } +} + const getTraining = () => { if (!selectedCert.value) return; - $api - .post("/plugin/training/flags", { - name: selectedCert.value, - answers: {}, - }) + $api.post("/plugin/training/flags", { name: selectedCert.value, answers: {} }) .then((data) => { getFlags(data.data); updateVisibleFlags(selectedBadge.value); @@ -68,60 +100,55 @@ function getEmptyDataObject() { badgeList: [], completedFlags: 0, completedBadges: 0, - certificateCodeList: [], }; } const updateVisibleFlags = (badge) => { - if (badge) { - selectedBadge.value = badge; - visibleFlagList.value = flagList.value.filter( - (flag) => flag.badge_name === selectedBadge.value.name - ); - } else { - visibleFlagList.value = flagList.value; - } + const active = badge?.name ? badge : null; + selectedBadge.value = active; + visibleFlagList.value = active + ? flagList.value.filter(f => f.badge_name === active.name) + : flagList.value; }; function isCardActive(index) { + // If a badge is selected, only allow its first incomplete flag + // once the *last* flag of the previous badge is completed. if (selectedBadge.value) { const badgeIndex = badgeList.value.findIndex( - (badge) => badge.name === selectedBadge.value.name + b => b.name === selectedBadge.value.name ); if (badgeIndex > 0) { + const prevBadgeName = badgeList.value[badgeIndex - 1]?.name; const earlierFlags = flagList.value.filter( - (flag) => flag.badge_name === badgeList.value[badgeIndex - 1].name + f => f.badge_name === prevBadgeName ); - if (!earlierFlags[earlierFlags.length - 1].completed) { - return false; - } + const lastCompleted = earlierFlags.at(-1)?.completed === true; // ← safe even if empty + if (!lastCompleted) return false; } } - return ( - (index === 0 && - visibleFlagList.value.length > 0 && - !visibleFlagList.value[0].completed) || - (visibleFlagList.value[index] && - !visibleFlagList.value[index].completed && - visibleFlagList.value[index - 1].completed) - ); + // Gate within the visible list + const current = visibleFlagList.value[index]; + const prev = visibleFlagList.value.at(index - 1); + + if (index === 0) { + return Boolean(current && !current.completed); + } + return Boolean(current && !current.completed && prev?.completed); } -function checkCertificateCompletion() { - if (completedBadges.value === badgeList.value.length) { +async function checkCertificateCompletion() { + if (badgeList.value.length && completedBadges.value === badgeList.value.length) { completedCertificate.value = true; - let code = certificateCodeList.value.sort( - (a, b) => a.toString().length - b.toString().length - ); - code = code.join(" "); - certificateCode.value = btoa(code); - const duration = 10000; - if (end.value === 0) end.value = Date.now() + duration; // spray confetti for 10 seconds + if (end.value === 0) end.value = Date.now() + duration; playConfetti(); + // optional: auto-open modal if no stored name + if (!learnerName.value.trim()) showIssueModal.value = true; } } + function compareFlags(currentBadge, iconSrc, flag, flagIndex) { const updatedFlag = { ...flag, @@ -138,13 +165,13 @@ function compareFlags(currentBadge, iconSrc, flag, flagIndex) { } return updatedFlag; } + function updateFlagData(newData) { if (newData) { flagList.value = newData.flagList; badgeList.value = newData.badgeList; completedFlags.value = newData.completedFlags; completedBadges.value = newData.completedBadges; - certificateCodeList.value = newData.certificateCodeList; } } @@ -153,8 +180,6 @@ async function getFlags(data) { const newData = getEmptyDataObject(); let runningFlagIndex = 0; - // Fetch flag from API and compares it to previous data, - // rather than completely override (for variables like showMore) data.badges.forEach((badge) => { const iconSrc = `/plugin/training/assets/img/badges/${badge.name}.png`; let isBadgeCompleted = false; @@ -164,7 +189,6 @@ async function getFlags(data) { const currentFlag = compareFlags(badge, iconSrc, flag, runningFlagIndex); if (currentFlag.completed) badgeCompletedFlags += 1; newData.flagList.push(currentFlag); - newData.certificateCodeList.push(currentFlag.code); runningFlagIndex += 1; }); @@ -178,51 +202,29 @@ async function getFlags(data) { completed: isBadgeCompleted, icon_src: iconSrc, }); - // Keep selected badge so it doesn't get overriden by new data - if (selectedBadge.value.name === badge.name) selectedBadge.value = badge; + + if (selectedBadge.value && selectedBadge.value.name === badge.name) { + selectedBadge.value = badge; + } newData.completedFlags += badgeCompletedFlags; }); updateFlagData(newData); } -function copyCode() { - document.getElementById("certificatecode").select(); - document.execCommand("copy"); - // toast('Code copied', true); -} function playConfetti() { const canvas = document.getElementById("confettiCanvas"); - if (!canvas || !confetti) return; - // eslint-disable-next-line - const confettiCanon = confetti.create(canvas, { - resize: true, - useWorker: true, - }); - + if (!canvas || typeof confetti === 'undefined') return; + const confettiCanon = confetti.create(canvas, { resize: true, useWorker: true }); const frame = () => { - // launch a few confetti from the left edge - confettiCanon({ - particleCount: 100, - angle: 60, - spread: 55, - origin: { x: 0 }, - }); - // and launch a few from the right edge - confettiCanon({ - particleCount: 100, - angle: 120, - spread: 55, - origin: { x: 1 }, - }); + confettiCanon({ particleCount: 100, angle: 60, spread: 55, origin: { x: 0 } }); + confettiCanon({ particleCount: 100, angle: 120, spread: 55, origin: { x: 1 } }); }; - - // keep going until we are out of time - if (Date.now() < end.value) { - requestAnimationFrame(frame); - } + const tick = () => { frame(); if (Date.now() < end.value) requestAnimationFrame(tick); }; + tick(); } +