Files
Chamilo/main/inc/lib/MailTemplateManager.php
T
Xes 73154ae174
Behat tests 1.11.x 🐞 / PHP 7.4 Test on ubuntu-latest (push) Canceled after 0s
PHP-CS-Fixer / composer_install (7.4) (push) Canceled after 0s
Chamilo 1.11.40 (ZIP oficial v1.11.40)
Fuente: https://github.com/chamilo/chamilo-lms/releases/download/v1.11.40/chamilo-1.11.40.zip
sha256: 1cf4bf2cc7bae1ef1a1eff643235db1d552f78ddf4b6dd1e2d2dac9868679439
Snapshot independiente (rama huerfana); diffable vs 1.11.38. vendor incluido.
2026-08-06 17:59:45 +02:00

357 lines
11 KiB
PHP

<?php
/* For licensing terms, see /license.txt */
use Chamilo\UserBundle\Entity\User;
use Symfony\Component\Finder\Finder;
use Twig\Environment;
use Twig\Extension\SandboxExtension;
use Twig\Loader\ArrayLoader;
use Twig\Sandbox\SecurityPolicy;
use Twig\TwigFilter;
use Twig\TwigFunction;
/**
* Class MailTemplateManager.
*/
class MailTemplateManager extends Model
{
/**
* Twig tags allowed inside an admin-stored mail template body.
*/
public const ALLOWED_TAGS = ['if', 'for', 'set', 'apply', 'spaceless', 'autoescape', 'with'];
/**
* Twig filters allowed inside an admin-stored mail template body. The
* callable-accepting gadget filters (filter/map/reduce/sort) are
* deliberately excluded to prevent SSTI → RCE.
*/
public const ALLOWED_FILTERS = [
'abs', 'capitalize', 'date', 'date_modify', 'default', 'escape', 'e',
'first', 'format', 'join', 'json_encode', 'keys', 'last', 'length',
'lower', 'merge', 'nl2br', 'number_format', 'raw', 'replace', 'reverse',
'round', 'slice', 'split', 'striptags', 'title', 'trim', 'upper',
'url_encode', 'get_lang',
];
/**
* Twig functions allowed inside an admin-stored mail template body.
*/
public const ALLOWED_FUNCTIONS = ['max', 'min', 'range', 'get_lang'];
public $columns = [
'id',
'name',
'template',
'type',
'system',
'url_id',
'default_template',
'created_at',
'updated_at',
'author_id',
];
public function __construct()
{
parent::__construct();
$this->table = 'mail_template';
}
/**
* @return int
*/
public function get_count()
{
$row = Database::select(
'count(*) as count',
$this->table,
['where' => ['url_id = ? ' => api_get_current_access_url_id()]],
'first'
);
return $row['count'];
}
/**
* Displays the title + grid.
*
* @return string html code
*/
public function display()
{
// Action links
$html = '<div class="actions" style="margin-bottom:20px">';
$html .= '<a href="'.api_get_path(WEB_CODE_PATH).'admin">'.
Display::return_icon(
'back.png',
get_lang('Back'),
'',
'32'
)
.'</a>';
$html .= '<a href="'.api_get_self().'?action=add">'.
Display::return_icon(
'add.png',
get_lang('Add'),
'',
'32'
).'</a>';
$html .= '</div>';
$html .= Display::grid_html('mail_template');
return $html;
}
/**
* Returns a Form validator Obj.
*
* @param string $url
* @param string $action
*
* @return FormValidator
*/
public function returnForm($url, $action = 'add')
{
$form = new FormValidator('template', 'post', $url);
// Setting the form elements
$header = get_lang('Add');
if ($action === 'edit') {
$header = get_lang('Modify');
}
$id = isset($_GET['id']) ? (int) $_GET['id'] : '';
$form->addElement('header', '', $header);
$form->addElement('hidden', 'id', $id);
$form->addElement(
'text',
'name',
get_lang('Name'),
['size' => '70', 'id' => 'name']
);
/*$form->addHtmlEditor(
'email_template',
get_lang('Template'),
false,
false,
[
'ToolbarSet' => 'Careers',
'Width' => '100%',
'Height' => '250',
]
);*/
$form->addTextarea(
'email_template',
get_lang('Template'),
['rows' => 20]
);
$form->addLabel(
get_lang('Allowed template syntax'),
$this->getAllowedSyntaxHelp()
);
$finder = new Finder();
$files = $finder
->files()
->in(api_get_path(SYS_CODE_PATH).'template/default/mail')
->sort(
function ($a, $b) {
return strcmp($a->getRealpath(), $b->getRealpath());
}
);
$options = [];
/** @var SplFileInfo $file */
foreach ($files as $file) {
$options[$file->getFilename()] = $file->getFilename();
}
$form->addSelect(
'type',
get_lang('Type'),
$options
);
$defaults = $this->get($id);
if ($action === 'edit') {
$form->addLabel(get_lang('CreatedAt'), Display::dateToStringAgoAndLongDate($defaults['created_at']));
$form->addLabel(get_lang('UpdatedAt'), Display::dateToStringAgoAndLongDate($defaults['updated_at']));
$form->addButtonSave(get_lang('Modify'), 'submit');
} else {
$form->addButtonCreate(get_lang('Add'), 'submit');
}
// Setting the defaults
if (!empty($defaults)) {
$defaults['email_template'] = $defaults['template'];
}
$form->setDefaults($defaults);
// Setting the rules
$form->addRule('name', get_lang('ThisFieldIsRequired'), 'required');
return $form;
}
/**
* @param int $id
*
* @return bool
*/
public function setDefault($id)
{
$template = $this->get($id);
if (empty($template)) {
return false;
}
$type = $template['type'];
$urlId = api_get_current_access_url_id();
$sql = "UPDATE {$this->table} SET default_template = 0
WHERE type = '$type' AND url_id = $urlId";
Database::query($sql);
$sql = "UPDATE {$this->table} SET default_template = 1
WHERE id = $id";
Database::query($sql);
return true;
}
/**
* @param int $templateId
* @param array $userInfo
*
* @return string|false
*/
public function parseTemplate($templateId, $userInfo)
{
$templateInfo = $this->get($templateId);
if (!empty($templateInfo)) {
$emailTemplate = nl2br($templateInfo['template']);
$keys = array_keys($userInfo);
foreach ($keys as $key) {
$emailTemplate = str_replace("{{user.$key}}", $userInfo[$key], $emailTemplate);
}
$template = new Template();
$template->twig->setLoader(new \Twig_Loader_String());
$emailBody = $template->twig->render($emailTemplate);
return $emailBody;
}
return false;
}
/**
* Gets a custom mail template by the name of the template it replaces.
*
* @param string $templateType Name of the template file it replaces
*
* @return string
*/
public function getTemplateByType($templateType)
{
if (empty($templateType)) {
return '';
}
$result = Database::select(
'template',
$this->table,
['where' => ['type = ? ' => $templateType, ' AND url_id = ? ' => api_get_current_access_url_id()]],
'first'
);
if (empty($result)) {
return '';
}
return $result['template'];
}
/**
* Builds the help block describing the Twig syntax allowed in a mail
* template, derived from the same allowlists enforced by
* renderSandboxedTemplate() so the form and the sandbox never drift apart.
*
* @return string
*/
public function getAllowedSyntaxHelp(): string
{
$tags = api_htmlentities(implode(', ', self::ALLOWED_TAGS));
$filters = api_htmlentities(implode(', ', self::ALLOWED_FILTERS));
$functions = api_htmlentities(implode(', ', self::ALLOWED_FUNCTIONS));
$html = '<div class="space-y-2 mt-2 rounded-lg border border-gray-25 bg-gray-15 p-4 text-sm text-gray-70">';
$html .= '<p><strong>'.api_htmlentities(get_lang('Tags')).':</strong> <code>'.$tags.'</code></p>';
$html .= '<p><strong>'.api_htmlentities(get_lang('Filters')).':</strong> <code>'.$filters.'</code></p>';
$html .= '<p><strong>'.api_htmlentities(get_lang('Functions')).':</strong> <code>'.$functions.'</code></p>';
$html .= '<p>'.api_htmlentities(get_lang('Available variables depend on the template type, for example {{ user.getUsername() }} or {{ user.getEmail() }}.')).'</p>';
$html .= '<p>'.api_htmlentities(get_lang('For security reasons, any other Twig function, filter (such as filter, map, reduce or sort) or PHP call is blocked.')).'</p>';
$html .= '</div>';
return $html;
}
/**
* Renders an admin-stored mail template body through a sandboxed Twig
* environment.
*
* Stored mail templates are untrusted content (any platform admin can edit
* them) and must never be compiled with the full application Twig: the
* non-sandboxed environment exposes the callable-accepting filters
* (filter/map/reduce/sort) that turn a template body into a Server-Side
* Template Injection → Remote Code Execution gadget.
*
* The sandbox here uses an explicit allow-list of tags, filters, functions
* and entity getters; everything else — including the RCE gadget filters —
* is rejected. When rendering is refused or fails, an empty string is
* returned so the caller falls back to the default file-based template.
*
* @param string $templateText The admin-stored Twig template body
* @param array $params The render context (template variables)
*
* @return string The rendered body, or '' when rendering is rejected
*/
public static function renderSandboxedTemplate(string $templateText, array $params): string
{
if ('' === trim($templateText)) {
return '';
}
$allowedMethods = [
User::class => [
'getId', 'getUsername', 'getFirstname', 'getLastname',
'getEmail', 'getStatus', 'getOfficialCode', 'getPhone',
],
];
$allowedProperties = [];
$policy = new SecurityPolicy(
self::ALLOWED_TAGS,
self::ALLOWED_FILTERS,
$allowedMethods,
$allowedProperties,
self::ALLOWED_FUNCTIONS
);
$twig = new Environment(
new ArrayLoader(['mail_template' => $templateText]),
['autoescape' => 'html', 'cache' => false, 'strict_variables' => false]
);
$twig->addExtension(new SandboxExtension($policy, true));
$twig->addFilter(new TwigFilter('get_lang', 'get_lang'));
$twig->addFunction(new TwigFunction('get_lang', 'get_lang'));
try {
return $twig->render('mail_template', $params);
} catch (Throwable $e) {
error_log('Refused to render stored mail template in sandbox: '.$e->getMessage());
return '';
}
}
}