Skip to content

Course: Replace legacy links with hashed public URLs and enforce 'publish' check for certificates - refs #3536 #6343

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion public/main/gradebook/gradebook_display_certificate.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,11 @@ function confirmation() {
echo '<td width="50%">'.get_lang('Score').' : '.$valueCertificate['score_certificate'].'</td>';
echo '<td width="30%">'.get_lang('Date').' : '.api_convert_and_format_date($valueCertificate['created_at']).'</td>';
echo '<td width="20%">';
$url = api_get_path(WEB_PATH).'certificates/index.php?id='.$valueCertificate['id'].'&user_id='.$value['user_id'];
$url = '';
if (!empty($valueCertificate['path_certificate']) && $valueCertificate['publish']) {
$hash = pathinfo($valueCertificate['path_certificate'], PATHINFO_FILENAME);
$url = api_get_path(WEB_PATH).'certificates/'.$hash.'.html';
}
$certificateUrl = Display::url(
get_lang('Certificate'),
$url,
Expand Down
23 changes: 18 additions & 5 deletions public/main/gradebook/lib/GradebookUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,8 @@ public static function get_list_gradebook_certificates_by_user_id(
gc.path_certificate,
gc.cat_id,
gc.user_id,
gc.id
gc.id,
gc.publish
FROM '.$table_certificate.' gc
WHERE gc.user_id = "'.$user_id.'" ';
if (!is_null($cat_id) && $cat_id > 0) {
Expand Down Expand Up @@ -1382,12 +1383,24 @@ public static function getUserCertificatesInCourses(
continue;
}

$path = $certificateInfo['path_certificate'] ?? '';
$publish = $certificateInfo['publish'] ?? 0;
$hash = pathinfo($path, PATHINFO_FILENAME);

$link = '';
$pdf = '';

if (!empty($hash) && $publish) {
$link = api_get_path(WEB_PATH) . "certificates/{$hash}.html";
$pdf = api_get_path(WEB_PATH)."certificates/{$hash}.pdf";
}

$courseList[] = [
'course' => $courseInfo['title'],
'score' => $certificateInfo['score_certificate'],
'date' => api_format_date($certificateInfo['created_at'], DATE_FORMAT_SHORT),
'link' => api_get_path(WEB_PATH)."certificates/index.php?id={$certificateInfo['id']}",
'pdf' => api_get_path(WEB_PATH)."certificates/index.php?id={$certificateInfo['id']}&user_id={$userId}&action=export",
'link' => $link,
'pdf' => $pdf,
];
}

Expand Down Expand Up @@ -1461,13 +1474,13 @@ public static function getUserCertificatesInSessions($userId, $includeNonPublicC
if (empty($certificateInfo)) {
continue;
}

$hash = pathinfo($certificateInfo['path_certificate'], PATHINFO_FILENAME);
$sessionList[] = [
'session' => $session['session_name'],
'course' => $course['title'],
'score' => $certificateInfo['score_certificate'],
'date' => api_format_date($certificateInfo['created_at'], DATE_FORMAT_SHORT),
'link' => api_get_path(WEB_PATH)."certificates/index.php?id={$certificateInfo['id']}",
'link' => api_get_path(WEB_PATH)."certificates/{$hash}.html",
];
}
}
Expand Down
43 changes: 27 additions & 16 deletions public/main/gradebook/lib/be/category.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -2097,31 +2097,42 @@ public static function generateUserCertificate(
}
}

if (!empty($fileWasGenerated)) {
$url = api_get_path(WEB_PATH).'certificates/index.php?id='.$my_certificate['id'].'&user_id='.$user_id;
$certificates = Display::toolbarButton(
get_lang('Display certificate'),
$url,
'eye',
'primary',
['target' => '_blank']
);
if (!empty($fileWasGenerated) && !empty($my_certificate['publish'])) {
$certificates = '';
$exportToPDF = null;
$pdfUrl = null;

if (!empty($my_certificate['pathCertificate'])) {
$hash = pathinfo($my_certificate['pathCertificate'], PATHINFO_FILENAME);

$url = api_get_path(WEB_PATH) . 'certificates/' . $hash . '.html';
$pdfUrl = api_get_path(WEB_PATH) . 'certificates/' . $hash . '.pdf';

$certificates = Display::toolbarButton(
get_lang('Display certificate'),
$url,
'eye',
'primary',
['target' => '_blank']
);

$exportToPDF = Display::url(
Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export to PDF')),
"$url&action=export"
);
$exportToPDF = Display::url(
Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export to PDF')),
$pdfUrl,
['target' => '_blank']
);
}

$hideExportLink = api_get_setting('hide_certificate_export_link');
$hideExportLinkStudent = api_get_setting('hide_certificate_export_link_students');
$hideExportLink = api_get_setting('gradebook.hide_certificate_export_link');
$hideExportLinkStudent = api_get_setting('gradebook.hide_certificate_export_link_students');
if ('true' === $hideExportLink || (api_is_student() && 'true' === $hideExportLinkStudent)) {
$exportToPDF = null;
}

$html = [
'certificate_link' => $certificates,
'pdf_link' => $exportToPDF,
'pdf_url' => "$url&action=export",
'pdf_url' => $pdfUrl,
];
}

Expand Down
7 changes: 5 additions & 2 deletions public/main/inc/lib/certificate.lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,11 @@ public static function sendNotification(
}

$currentUserInfo = api_get_user_info();
$url = api_get_path(WEB_PATH).
'certificates/index.php?id='.$certificateInfo['id'].'&user_id='.$certificateInfo['user_id'];
$url = '';
if (!empty($certificateInfo['path_certificate'])) {
$hash = pathinfo($certificateInfo['path_certificate'], PATHINFO_FILENAME);
$url = api_get_path(WEB_PATH) . 'certificates/' . $hash . '.html';
}
$link = Display::url($url, $url);

$replace = [
Expand Down
5 changes: 4 additions & 1 deletion public/main/inc/lib/document.lib.php
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,10 @@ public static function get_all_info_to_certificate($user_id, $courseId, $session
$url = '';
if ($info_grade_certificate) {
$date_certificate = $info_grade_certificate['created_at'];
$url = api_get_path(WEB_PATH).'certificates/index.php?id='.$info_grade_certificate['id'];
if (!empty($info_grade_certificate['path_certificate'])) {
$hash = pathinfo($info_grade_certificate['path_certificate'], PATHINFO_FILENAME);
$url = api_get_path(WEB_PATH) . 'certificates/' . $hash . '.html';
}
}
$date_no_time = api_convert_and_format_date(api_get_utc_datetime(), DATE_FORMAT_LONG_NO_DAY);
if (!empty($date_certificate)) {
Expand Down
5 changes: 2 additions & 3 deletions public/main/my_space/session_filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -383,9 +383,8 @@
echo get_lang('Date').' : '.api_convert_and_format_date($valueCertificate['created_at']);
echo '</td>';
echo '<td width="20%">';
$url = api_get_path(WEB_PATH).'certificates/index.php?'.
'id='.$valueCertificate['id'].
'&user_id='.$value['user_id'];
$hash = pathinfo($valueCertificate['path_certificate'], PATHINFO_FILENAME);
$url = api_get_path(WEB_PATH)."certificates/{$hash}.html";
$certificateUrl = Display::url(
get_lang('Certificate'),
$url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ $(function () {
</td>
<td>
{% for certificate in student.certificates %}
<a href="{{ _p.web }}certificates/index.php?id={{ certificate.id }}" class="btn btn--plain">
{% set hash = certificate.path_certificate|split('.')|first %}
<a href="{{ _p.web }}certificates/{{ hash }}.html" class="btn btn--plain">
<em class="fa fa-floppy-o"></em> {{ 'Certificate' | get_lang }}
</a>
{% endfor %}
Expand Down
110 changes: 110 additions & 0 deletions src/CoreBundle/Controller/CertificateController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

/* For licensing terms, see /license.txt */

declare(strict_types=1);

namespace Chamilo\CoreBundle\Controller;

use Chamilo\CoreBundle\Repository\GradebookCertificateRepository;
use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CoreBundle\Framework\Container;
use Mpdf\Mpdf;
use Mpdf\MpdfException;
use Mpdf\Output\Destination;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

#[Route('/certificates')]
class CertificateController extends AbstractController
{
public function __construct(
private readonly GradebookCertificateRepository $certificateRepository,
private readonly SettingsManager $settingsManager
) {}

#[Route('/{hash}.html', name: 'chamilo_certificate_public_view', methods: ['GET'])]
public function view(string $hash): Response
{
// Build the expected certificate filename from the hash
$filename = $hash . '.html';

// Look up the certificate record by its path
$certificate = $this->certificateRepository->findOneBy([
'pathCertificate' => $filename,
]);

if (!$certificate) {
throw new NotFoundHttpException('The requested certificate does not exist.');
}

// Check if public access is globally allowed and certificate is marked as published
$allowPublic = 'true' === $this->settingsManager->getSetting('course.allow_public_certificates', true);
if (!$allowPublic || !$certificate->getPublish()) {
throw new AccessDeniedHttpException('The requested certificate is not public.');
}

// Fetch the actual certificate file from personal files using its title
$personalFileRepo = Container::getPersonalFileRepository();
$personalFile = $personalFileRepo->findOneBy(['title' => $filename]);

if (!$personalFile) {
throw new NotFoundHttpException('The certificate file was not found.');
}

// Read the certificate HTML content and sanitize for print compatibility
$content = $personalFileRepo->getResourceFileContent($personalFile);
$content = str_replace(' media="screen"', '', $content);

// Return the certificate as a raw HTML response
return new Response('<!DOCTYPE html>' . $content, 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}

#[Route('/{hash}.pdf', name: 'chamilo_certificate_public_pdf', methods: ['GET'])]
public function downloadPdf(string $hash): Response
{
$filename = $hash . '.html';

$certificate = $this->certificateRepository->findOneBy(['pathCertificate' => $filename]);
if (!$certificate) {
throw $this->createNotFoundException('The requested certificate does not exist.');
}

$allowPublic = 'true' === $this->settingsManager->getSetting('course.allow_public_certificates', true);
if (!$allowPublic || !$certificate->getPublish()) {
throw $this->createAccessDeniedException('The requested certificate is not public.');
}

$personalFileRepo = Container::getPersonalFileRepository();
$personalFile = $personalFileRepo->findOneBy(['title' => $filename]);
if (!$personalFile) {
throw $this->createNotFoundException('The certificate file was not found.');
}

$html = $personalFileRepo->getResourceFileContent($personalFile);
$html = str_replace(' media="screen"', '', $html);

try {
$mpdf = new Mpdf([
'format' => 'A4',
'tempDir' => api_get_path(SYS_ARCHIVE_PATH).'mpdf/',
]);
$mpdf->WriteHTML($html);
return new Response(
$mpdf->Output('certificate.pdf', Destination::DOWNLOAD),
200,
[
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="certificate.pdf"',
]
);
} catch (MpdfException $e) {
throw new \RuntimeException('Failed to generate PDF: '.$e->getMessage(), 500, $e);
}
}
}
Loading