fix certificate qr
This commit is contained in:
@@ -180,6 +180,7 @@ $routes->get('administrator/trophy/final', 'View\TrophyController::final'
|
||||
|
||||
// Certificates
|
||||
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
|
||||
$routes->get('administrator/certificates/csrf-token', 'View\CertificateController::csrfToken', ['filter' => 'auth:admin']);
|
||||
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
|
||||
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
|
||||
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
|
||||
|
||||
@@ -77,6 +77,17 @@ class CertificateController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
public function csrfToken()
|
||||
{
|
||||
return $this->response
|
||||
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
->setHeader('Pragma', 'no-cache')
|
||||
->setJSON([
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Public verification page ──────────────────────────────────────────────
|
||||
|
||||
public function verify(string $certNumber)
|
||||
@@ -101,6 +112,16 @@ class CertificateController extends BaseController
|
||||
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
|
||||
|
||||
if (empty($studentIds)) {
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response
|
||||
->setStatusCode(422)
|
||||
->setJSON([
|
||||
'ok' => false,
|
||||
'error' => 'Please select at least one student.',
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.');
|
||||
}
|
||||
|
||||
@@ -108,6 +129,16 @@ class CertificateController extends BaseController
|
||||
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
|
||||
|
||||
if (empty($studentIds)) {
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response
|
||||
->setStatusCode(422)
|
||||
->setJSON([
|
||||
'ok' => false,
|
||||
'error' => 'Invalid student selection.',
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->to('administrator/certificates')->with('error', 'Invalid student selection.');
|
||||
}
|
||||
|
||||
@@ -140,6 +171,16 @@ class CertificateController extends BaseController
|
||||
}
|
||||
|
||||
if (empty($students)) {
|
||||
if ($this->request->isAJAX()) {
|
||||
return $this->response
|
||||
->setStatusCode(422)
|
||||
->setJSON([
|
||||
'ok' => false,
|
||||
'error' => 'No valid students found.',
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
}
|
||||
return redirect()->to('administrator/certificates')->with('error', 'No valid students found.');
|
||||
}
|
||||
|
||||
@@ -170,6 +211,8 @@ class CertificateController extends BaseController
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'application/pdf')
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||
->setHeader('X-CSRF-TOKEN-NAME', csrf_token())
|
||||
->setHeader('X-CSRF-TOKEN', csrf_hash())
|
||||
->setBody($pdfData);
|
||||
}
|
||||
|
||||
@@ -204,30 +247,6 @@ class CertificateController extends BaseController
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a QR code PNG for $url and returns its temporary file path.
|
||||
* Caller must unlink() the file when done.
|
||||
*/
|
||||
private function makeQrTempFile(string $url): ?string
|
||||
{
|
||||
try {
|
||||
$result = \Endroid\QrCode\Builder\Builder::create()
|
||||
->writer(new \Endroid\QrCode\Writer\PngWriter())
|
||||
->data($url)
|
||||
->encoding(new \Endroid\QrCode\Encoding\Encoding('UTF-8'))
|
||||
->size(300)
|
||||
->margin(2)
|
||||
->build();
|
||||
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'cert_qr_') . '.png';
|
||||
$result->saveToFile($tmp);
|
||||
return $tmp;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Certificate QR generation failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF rendering ─────────────────────────────────────────────────────────
|
||||
|
||||
private function buildPdf(array $students, string $certDate): string
|
||||
@@ -252,8 +271,6 @@ class CertificateController extends BaseController
|
||||
$W = $pdf->getPageWidth();
|
||||
$H = $pdf->getPageHeight();
|
||||
|
||||
$tmpFiles = [];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$pdf->AddPage();
|
||||
|
||||
@@ -261,28 +278,18 @@ class CertificateController extends BaseController
|
||||
$grade = $this->formatGrade($student['grade'] ?? '');
|
||||
$certNumber = $student['cert_number'] ?? '';
|
||||
|
||||
// Build verification URL and generate QR temp file
|
||||
$verifyUrl = base_url('verify/' . rawurlencode($certNumber));
|
||||
$qrFile = $certNumber !== '' ? $this->makeQrTempFile($verifyUrl) : null;
|
||||
if ($qrFile) {
|
||||
$tmpFiles[] = $qrFile;
|
||||
}
|
||||
// Build verification URL for an inline TCPDF QR code.
|
||||
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
|
||||
|
||||
$this->drawCertificate(
|
||||
$pdf, $W, $H,
|
||||
$name, $grade, $certDate, $certNumber,
|
||||
$qrFile,
|
||||
$verifyUrl,
|
||||
$imgDir, $edwardianFont, $garamondBold, $ebGaramond
|
||||
);
|
||||
}
|
||||
|
||||
$output = $pdf->Output('', 'S');
|
||||
|
||||
foreach ($tmpFiles as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
|
||||
return $output;
|
||||
return $pdf->Output('', 'S');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +304,7 @@ class CertificateController extends BaseController
|
||||
string $grade,
|
||||
string $certDate,
|
||||
string $certNumber,
|
||||
?string $qrFile,
|
||||
?string $verifyUrl,
|
||||
string $imgDir,
|
||||
string $edwardianFont,
|
||||
string $garamondBold,
|
||||
@@ -310,10 +317,16 @@ class CertificateController extends BaseController
|
||||
|
||||
// ── QR code — bottom-right corner: 1.5 cm from bottom, 2.5 cm from right
|
||||
$qrSize = 42; // pt
|
||||
if ($qrFile !== null && file_exists($qrFile)) {
|
||||
if (!empty($verifyUrl)) {
|
||||
$qrX = $W - 70.87 - $qrSize; // 2.5 cm from right edge
|
||||
$qrY = $H - 42.52 - $qrSize; // 1.5 cm from bottom edge
|
||||
$pdf->Image($qrFile, $qrX, $qrY, $qrSize, $qrSize);
|
||||
$style = [
|
||||
'border' => false,
|
||||
'padding' => 0,
|
||||
'fgcolor' => [0, 0, 0],
|
||||
'bgcolor' => false,
|
||||
];
|
||||
$pdf->write2DBarcode($verifyUrl, 'QRCODE,L', $qrX, $qrY, $qrSize, $qrSize, $style, 'N');
|
||||
}
|
||||
|
||||
// ── "Presented to:"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<?php if (!empty($students)): ?>
|
||||
<!-- Certificate generation form -->
|
||||
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>" id="certForm" target="_blank">
|
||||
<form method="post" action="<?= site_url('administrator/certificates/generate') ?>" id="certForm">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="class_section_id" value="<?= esc($selectedClassId) ?>">
|
||||
<input type="hidden" name="school_year" value="<?= esc($schoolYear) ?>">
|
||||
@@ -122,6 +122,103 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const certForm = document.getElementById('certForm');
|
||||
const csrfRefreshUrl = <?= json_encode(site_url('administrator/certificates/csrf-token'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
let currentCsrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
|
||||
function syncCsrfField() {
|
||||
if (!certForm) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let tokenInput = certForm.querySelector(`input[name="${cssEscape(currentCsrfTokenName)}"]`);
|
||||
if (!tokenInput) {
|
||||
tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = currentCsrfTokenName;
|
||||
certForm.appendChild(tokenInput);
|
||||
}
|
||||
|
||||
return tokenInput.value || '';
|
||||
}
|
||||
|
||||
function updateCsrfToken(name, hash) {
|
||||
if (!certForm || !name || !hash) {
|
||||
return;
|
||||
}
|
||||
|
||||
certForm.querySelectorAll('input[type="hidden"]').forEach((input) => {
|
||||
if (input.name === name || input.name === currentCsrfTokenName) {
|
||||
input.name = name;
|
||||
input.value = hash;
|
||||
}
|
||||
});
|
||||
|
||||
let tokenInput = certForm.querySelector(`input[name="${cssEscape(name)}"]`);
|
||||
if (!tokenInput) {
|
||||
tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = name;
|
||||
certForm.appendChild(tokenInput);
|
||||
}
|
||||
tokenInput.value = hash;
|
||||
currentCsrfTokenName = name;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') {
|
||||
return window.CSS.escape(value);
|
||||
}
|
||||
return String(value).replace(/(["\\.#:[\],= ])/g, '\\$1');
|
||||
}
|
||||
|
||||
function showInlineError(message) {
|
||||
const existing = document.getElementById('certFormError');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
const alert = document.createElement('div');
|
||||
alert.id = 'certFormError';
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show';
|
||||
alert.setAttribute('role', 'alert');
|
||||
alert.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
|
||||
const container = document.querySelector('.container-fluid.py-4');
|
||||
const firstCard = document.querySelector('.card.shadow-sm.mb-4');
|
||||
if (container && firstCard) {
|
||||
container.insertBefore(alert, firstCard);
|
||||
} else if (container) {
|
||||
container.prepend(alert);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCsrfToken() {
|
||||
if (!certForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(csrfRefreshUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh CSRF token.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.csrf_token && data?.csrf_hash) {
|
||||
updateCsrfToken(data.csrf_token, data.csrf_hash);
|
||||
} else {
|
||||
throw new Error('Invalid CSRF token response.');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync date picker → hidden field (MM/DD/YYYY format for the certificate)
|
||||
const datePicker = document.getElementById('certDatePicker');
|
||||
const dateHidden = document.getElementById('certDateHidden');
|
||||
@@ -142,6 +239,92 @@
|
||||
const checks = document.querySelectorAll('.student-check');
|
||||
const generateBtn = document.getElementById('generateBtn');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
let pdfWindow = null;
|
||||
let activePdfBlobUrl = null;
|
||||
|
||||
function renderPdfWindowShell(targetWindow) {
|
||||
if (!targetWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetWindow.document.open();
|
||||
targetWindow.document.write(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Certificate PDF</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #f3f4f6;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.viewer-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.viewer-status {
|
||||
padding: 12px 16px;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.viewer-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="viewer-shell">
|
||||
<div class="viewer-status" id="viewerStatus">Preparing certificate PDF...</div>
|
||||
<iframe class="viewer-frame" id="pdfFrame" title="Certificate PDF preview"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
window.showCertificatePdf = function (url, filename) {
|
||||
const frame = document.getElementById('pdfFrame');
|
||||
const status = document.getElementById('viewerStatus');
|
||||
if (status) {
|
||||
status.textContent = filename ? ('Showing ' + filename) : 'Certificate PDF ready';
|
||||
}
|
||||
if (frame) {
|
||||
frame.src = url;
|
||||
}
|
||||
document.title = filename || 'Certificate PDF';
|
||||
};
|
||||
<\/script>
|
||||
</body>
|
||||
</html>`);
|
||||
targetWindow.document.close();
|
||||
}
|
||||
|
||||
function openPdfWindow() {
|
||||
if (!pdfWindow || pdfWindow.closed) {
|
||||
pdfWindow = window.open('', 'certificatePdfWindow');
|
||||
}
|
||||
|
||||
if (!pdfWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
renderPdfWindowShell(pdfWindow);
|
||||
return pdfWindow;
|
||||
} catch (error) {
|
||||
pdfWindow = window.open('', 'certificatePdfWindow');
|
||||
if (!pdfWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
renderPdfWindowShell(pdfWindow);
|
||||
return pdfWindow;
|
||||
}
|
||||
}
|
||||
|
||||
function updateState() {
|
||||
const chosen = document.querySelectorAll('.student-check:checked').length;
|
||||
@@ -160,6 +343,100 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (certForm) {
|
||||
certForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const chosen = document.querySelectorAll('.student-check:checked').length;
|
||||
if (chosen === 0) {
|
||||
showInlineError('Please select at least one student.');
|
||||
return;
|
||||
}
|
||||
|
||||
const previewWindow = openPdfWindow();
|
||||
if (!previewWindow) {
|
||||
showInlineError('Unable to open the certificate PDF tab. Please allow pop-ups for this site.');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHtml = generateBtn.innerHTML;
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Generating...';
|
||||
|
||||
try {
|
||||
await refreshCsrfToken();
|
||||
|
||||
const csrfValue = syncCsrfField();
|
||||
const formData = new FormData(certForm);
|
||||
if (csrfValue) {
|
||||
formData.set(currentCsrfTokenName, csrfValue);
|
||||
}
|
||||
|
||||
const response = await fetch(certForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
|
||||
const nextCsrfName = response.headers.get('X-CSRF-TOKEN-NAME') || currentCsrfTokenName;
|
||||
const nextCsrfHash = response.headers.get('X-CSRF-TOKEN');
|
||||
if (nextCsrfName && nextCsrfHash) {
|
||||
updateCsrfToken(nextCsrfName, nextCsrfHash);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (!response.ok || !contentType.toLowerCase().includes('application/pdf')) {
|
||||
let message = 'Certificate generation failed.';
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data?.csrf_token && data?.csrf_hash) {
|
||||
updateCsrfToken(data.csrf_token, data.csrf_hash);
|
||||
}
|
||||
if (data?.error) {
|
||||
message = data.error;
|
||||
}
|
||||
} catch (jsonError) {
|
||||
const text = await response.text();
|
||||
if (text) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
pdfWindow.close();
|
||||
showInlineError(message);
|
||||
await refreshCsrfToken();
|
||||
return;
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition') || '';
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/i);
|
||||
const filename = filenameMatch ? filenameMatch[1] : 'Certificates.pdf';
|
||||
const pdfBlob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(pdfBlob);
|
||||
|
||||
if (activePdfBlobUrl) {
|
||||
URL.revokeObjectURL(activePdfBlobUrl);
|
||||
}
|
||||
|
||||
activePdfBlobUrl = blobUrl;
|
||||
previewWindow.showCertificatePdf(blobUrl, filename);
|
||||
await refreshCsrfToken();
|
||||
} catch (error) {
|
||||
showInlineError('Certificate generation failed. Please try again.');
|
||||
try {
|
||||
await refreshCsrfToken();
|
||||
} catch (csrfError) {
|
||||
// Leave the current token in place if refresh also fails.
|
||||
}
|
||||
} finally {
|
||||
generateBtn.innerHTML = originalHtml;
|
||||
updateState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checks.forEach(c => c.addEventListener('change', updateState));
|
||||
updateState();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user