mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
* Fix cron-sent emails rendering dates in en_US instead of configured locale In CLI/cron context there is no Accept-Language header, so Locale::acceptFromHttp() fails and Locale::lookup() with an empty detected locale returns the first enabled locale (en_US) instead of null, overriding the configured i18n.locale default. Guard getBrowserLocale() against empty/unparsable headers so it falls back to the configured default, and stop queueing empty locale cookies. Also plumb an explicit per-recipient locale through email rendering: TwigFactory::createEmailEnvironment(), System render helpers, and Email::sendTemplate() now honor the recipient's language (client lang, per staff member on broadcasts), outranking browser auto-detection, with Massmailer rendering per-client too. Fixes #4524 * Fix tests: simulate header-less requests, update subject render expectation Symfony's Request::create() seeds a default Accept-Language header, so strip it to reproduce the CLI/cron condition in the new getActiveLocale fallback tests. Update the existing renderEmailSubjectString test for the new locale parameter.
963 lines
39 KiB
PHP
963 lines
39 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* Copyright 2022-2025 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
|
*/
|
|
|
|
namespace Box\Mod\System;
|
|
|
|
use Box\Mod\Cron\Event\BeforeAdminCronRunEvent;
|
|
use Box\Mod\System\Entity\Setting;
|
|
use Box\Mod\System\Event\AfterAdminSettingsUpdateEvent;
|
|
use Box\Mod\System\Event\BeforeAdminSettingsUpdateEvent;
|
|
use Box\Mod\System\Repository\SettingRepository;
|
|
use Doctrine\DBAL\Connection;
|
|
use Doctrine\DBAL\Exception\DeadlockException;
|
|
use Doctrine\DBAL\Exception\LockWaitTimeoutException;
|
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
|
use Doctrine\DBAL\Platforms\SQLitePlatform;
|
|
use FOSSBilling\Cache\CacheFactory;
|
|
use FOSSBilling\Config;
|
|
use FOSSBilling\Doctrine\EntityManagerFactory;
|
|
use FOSSBilling\Doctrine\RowLock;
|
|
use FOSSBilling\Environment;
|
|
use FOSSBilling\GeoIP\Reader;
|
|
use FOSSBilling\Period;
|
|
use FOSSBilling\Sanitizer\BrowserHtmlSanitizer;
|
|
use FOSSBilling\SentryHelper;
|
|
use FOSSBilling\Twig\SandboxedStringRenderer;
|
|
use FOSSBilling\Version;
|
|
use Pimple\Container;
|
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
use Symfony\Component\Filesystem\Filesystem;
|
|
use Symfony\Component\Filesystem\Path;
|
|
use Symfony\Contracts\Cache\ItemInterface;
|
|
|
|
class Service
|
|
{
|
|
protected ?Container $di = null;
|
|
|
|
protected SettingRepository $settingRepository;
|
|
|
|
private Filesystem $filesystem;
|
|
|
|
public function __construct(?Filesystem $filesystem = null)
|
|
{
|
|
$this->filesystem = $filesystem ?? new Filesystem();
|
|
}
|
|
|
|
public function setDi(Container $di): void
|
|
{
|
|
$this->di = $di;
|
|
$this->settingRepository = $di['em']->getRepository(Setting::class);
|
|
if (isset($di['filesystem'])) {
|
|
$this->filesystem = $di['filesystem'];
|
|
}
|
|
}
|
|
|
|
public function getModulePermissions(): array
|
|
{
|
|
return [
|
|
'view' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('View System Information'),
|
|
'description' => __trans('Allows the staff member to view system status, update availability, and other read-only system information.'),
|
|
],
|
|
'manage_settings' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Manage System Settings'),
|
|
'description' => __trans('Allows the staff member to view and manage general system settings.'),
|
|
],
|
|
'manage_company_details' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Manage Company Details'),
|
|
'description' => __trans('Allows the staff member to update company details as set under the system module.'),
|
|
],
|
|
'manage_company_legal' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Manage Company Legal'),
|
|
'description' => __trans('Allows the staff member to update company legal as set under the system module.'),
|
|
],
|
|
'update_params' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Update System Parameters'),
|
|
'description' => __trans('Allows the staff member to update system parameters through the system API endpoint.'),
|
|
],
|
|
'invalidate_cache' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Invalidate Cache'),
|
|
'description' => __trans('Allows the staff member to invalidate the FOSSBilling cache from within the system settings.'),
|
|
],
|
|
'system_update' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Update FOSSBilling'),
|
|
'description' => __trans('Allows the staff member to update FOSSBilling.'),
|
|
],
|
|
'recheck_update' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Recheck for Updates'),
|
|
'description' => __trans('Allows the staff member to clear cached update information and fetch the latest update metadata.'),
|
|
],
|
|
'toggle_error_reporting' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Toggle Error Reporting'),
|
|
'description' => __trans('Allows the staff member to enable or disable error reporting for this FOSSBilling instance.'),
|
|
],
|
|
'manage_network_interface' => [
|
|
'type' => 'bool',
|
|
'display_name' => __trans('Manage the Network Interface'),
|
|
'description' => __trans('Allows the staff member to fetch a list of all local interface IP addresses and set the default network interface for FOSSBilling to use.'),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function getParamValue(string $param, $default = null)
|
|
{
|
|
if (empty($param)) {
|
|
throw new \FOSSBilling\Exception('Parameter key is missing.');
|
|
}
|
|
|
|
$setting = $this->settingRepository->findOneByParam($param);
|
|
if ($setting === null) {
|
|
return $default;
|
|
}
|
|
|
|
return $setting->getValue();
|
|
}
|
|
|
|
public function setParamValue($param, $value, $createIfNotExists = true): bool
|
|
{
|
|
$this->writeParamValue((string) $param, $value, $createIfNotExists);
|
|
$this->di['em']->flush();
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Stage a setting write without flushing; the caller controls when the batch is flushed.
|
|
* A failed flush closes the EntityManager, so the constraint conflict is never swallowed here.
|
|
*/
|
|
private function writeParamValue(string $param, $value, bool $createIfNotExists): void
|
|
{
|
|
$value = $value === null ? null : (string) $value;
|
|
|
|
// Normalize the key so the permission check and the lookup below
|
|
// agree on it, then reject anything outside the canonical charset.
|
|
$param = strtolower($param);
|
|
if (!preg_match('/^[a-z0-9_]+$/', $param)) {
|
|
throw new \FOSSBilling\InformationException('Invalid parameter name, received: param_.', ['param_' => $param]);
|
|
}
|
|
|
|
// Skip this param if the user isn't permitted to update it.
|
|
if (!$this->canUpdateParam($param)) {
|
|
return;
|
|
}
|
|
|
|
$setting = $this->settingRepository->findOneByParam($param);
|
|
if ($setting !== null) {
|
|
$setting->setValue($value);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!$createIfNotExists) {
|
|
return;
|
|
}
|
|
|
|
$setting = new Setting();
|
|
$setting->setParam($param);
|
|
$setting->setValue($value);
|
|
$this->di['em']->persist($setting);
|
|
}
|
|
|
|
public function paramExists($param): bool
|
|
{
|
|
return $this->settingRepository->findOneByParam((string) $param) !== null;
|
|
}
|
|
|
|
/**
|
|
* Fetch setting values for the provided setting keys.
|
|
*
|
|
* @param string[] $params
|
|
*
|
|
* @return mixed[]
|
|
*/
|
|
private function getSettingsByParams(array $params): array
|
|
{
|
|
foreach ($params as $param) {
|
|
if (!preg_match('/^[a-z0-9_]+$/', (string) $param)) {
|
|
throw new \FOSSBilling\InformationException('Invalid parameter name, received: param_.', ['param_' => $param]);
|
|
}
|
|
}
|
|
$result = [];
|
|
foreach ($this->settingRepository->findByParams($params) as $setting) {
|
|
$result[(string) $setting->getParam()] = $setting->getValue();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getCompany(): array
|
|
{
|
|
$c = [
|
|
'company_name',
|
|
'company_email',
|
|
'company_tel',
|
|
'company_signature',
|
|
'company_logo',
|
|
'company_logo_dark',
|
|
'company_favicon',
|
|
'company_address_1',
|
|
'company_address_2',
|
|
'company_address_3',
|
|
'company_bank_name',
|
|
'company_bic',
|
|
'company_display_bank_info',
|
|
'company_bank_info_pagebottom',
|
|
'company_account_number',
|
|
'company_number',
|
|
'company_note',
|
|
'company_privacy_policy',
|
|
'company_tos',
|
|
'company_vat_number',
|
|
];
|
|
$results = $this->getSettingsByParams($c);
|
|
|
|
$logoUrl = $results['company_logo'] ?? null;
|
|
if ($logoUrl !== null && !str_contains((string) $logoUrl, 'http')) {
|
|
$logoUrl = SYSTEM_URL . $logoUrl;
|
|
}
|
|
|
|
$logoUrlDark = $results['company_logo_dark'] ?? null;
|
|
if ($logoUrlDark !== null && !str_contains((string) $logoUrlDark, 'http')) {
|
|
$logoUrlDark = SYSTEM_URL . $logoUrlDark;
|
|
}
|
|
$logoUrlDark ??= $logoUrl;
|
|
|
|
$faviconUrl = $results['company_favicon'] ?? null;
|
|
if ($faviconUrl !== null && !str_contains((string) $faviconUrl, 'http')) {
|
|
$faviconUrl = SYSTEM_URL . $faviconUrl;
|
|
}
|
|
|
|
// Returned raw: output contexts escape on render, so escaping here
|
|
// double-escapes in templates and corrupts stored snapshots (#4305).
|
|
return [
|
|
'www' => SYSTEM_URL,
|
|
'name' => isset($results['company_name']) ? (string) $results['company_name'] : null,
|
|
'email' => isset($results['company_email']) ? (string) $results['company_email'] : null,
|
|
'tel' => isset($results['company_tel']) ? (string) $results['company_tel'] : null,
|
|
'signature' => $results['company_signature'] ?? null,
|
|
'logo_url' => $logoUrl,
|
|
'logo_url_dark' => $logoUrlDark,
|
|
'favicon_url' => $faviconUrl,
|
|
'address_1' => isset($results['company_address_1']) ? (string) $results['company_address_1'] : null,
|
|
'address_2' => isset($results['company_address_2']) ? (string) $results['company_address_2'] : null,
|
|
'address_3' => isset($results['company_address_3']) ? (string) $results['company_address_3'] : null,
|
|
'account_number' => $results['company_account_number'] ?? null,
|
|
'bank_name' => isset($results['company_bank_name']) ? (string) $results['company_bank_name'] : null,
|
|
'bic' => isset($results['company_bic']) ? (string) $results['company_bic'] : null,
|
|
'display_bank_info' => $results['company_display_bank_info'] ?? null,
|
|
'bank_info_pagebottom' => $results['company_bank_info_pagebottom'] ?? null,
|
|
'number' => isset($results['company_number']) ? (string) $results['company_number'] : null,
|
|
'note' => $results['company_note'] ?? null,
|
|
'privacy_policy' => $results['company_privacy_policy'] ?? null,
|
|
'tos' => $results['company_tos'] ?? null,
|
|
'vat_number' => isset($results['company_vat_number']) ? (string) $results['company_vat_number'] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return mixed[]
|
|
*/
|
|
public function getParams($data): array
|
|
{
|
|
$result = [];
|
|
foreach ($this->settingRepository->findAll() as $setting) {
|
|
$result[(string) $setting->getParam()] = $setting->getValue();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function updateParams($data): bool
|
|
{
|
|
$parameterNames = array_map(static fn (int|string $key): string => (string) $key, array_keys($data));
|
|
$this->di['event_dispatcher']->dispatch(new BeforeAdminSettingsUpdateEvent($parameterNames));
|
|
|
|
foreach ($data as $key => $val) {
|
|
if (!$this->canUpdateParam($key)) {
|
|
throw new \FOSSBilling\InformationException('You do not have permission to update the parameter :param', [':param' => $key]);
|
|
}
|
|
|
|
if (strtolower((string) $key) === 'invoice_starting_number'
|
|
&& ((!is_int($val) && !is_string($val))
|
|
|| filter_var($val, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX - 1]]) === false)) {
|
|
throw new \FOSSBilling\InformationException('Next invoice number must be a positive whole number no greater than :max.', [':max' => PHP_INT_MAX - 1]);
|
|
}
|
|
|
|
if (strtolower((string) $key) === 'invoice_reminder_send_time'
|
|
&& $val !== null && trim((string) $val) !== ''
|
|
&& !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', trim((string) $val))) {
|
|
throw new \FOSSBilling\InformationException('Reminder send time must be empty or a valid 24-hour time (HH:MM).');
|
|
}
|
|
}
|
|
|
|
foreach ($data as $key => $val) {
|
|
$this->writeParamValue((string) $key, $val, true);
|
|
}
|
|
|
|
// Flush the batch once; a unique-constraint collision surfaces to the caller.
|
|
$this->di['em']->flush();
|
|
|
|
$this->di['event_dispatcher']->dispatch(new AfterAdminSettingsUpdateEvent());
|
|
|
|
$this->di['logger']->info('Updated system general settings');
|
|
|
|
return true;
|
|
}
|
|
|
|
private function createAdminAlert(
|
|
string $type,
|
|
string $message,
|
|
?string $title = null,
|
|
array $buttons = [],
|
|
bool $dismissible = true,
|
|
): array {
|
|
$defaultTitles = [
|
|
'danger' => __trans('Danger!'),
|
|
'warning' => __trans('Warning'),
|
|
'info' => __trans('Information'),
|
|
'success' => __trans('Success'),
|
|
];
|
|
|
|
return [
|
|
'type' => $type,
|
|
'title' => $title ?? ($defaultTitles[$type] ?? __trans('Notice')),
|
|
'message' => $message,
|
|
'buttons' => $buttons,
|
|
'dismissible' => $dismissible,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return mixed[][]
|
|
*/
|
|
public function getMessages($type = null): array
|
|
{
|
|
$messages = [];
|
|
|
|
// Check if there's an update available
|
|
try {
|
|
$updater = $this->di['updater'];
|
|
if ($updater->isUpdateAvailable()) {
|
|
$version = $updater->getLatestVersion();
|
|
$updateUrl = $this->di['url']->adminLink('system/update');
|
|
$messages[] = $this->createAdminAlert(
|
|
'info',
|
|
__trans('FOSSBilling :version is available for download.', [':version' => $version]),
|
|
__trans('Update Available'),
|
|
[[
|
|
'link' => $updateUrl,
|
|
'text' => __trans('Review Update'),
|
|
'type' => 'primary',
|
|
]]
|
|
);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->di['logger']->error($e->getMessage());
|
|
}
|
|
|
|
// Check if FOSSBilling is behind on database patches
|
|
try {
|
|
$updater = $this->di['updater'];
|
|
if ($updater->isBehindOnDBPatches()) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'warning',
|
|
__trans('Your FOSSBilling database is behind on database patches. Apply the pending patches to avoid issues.'),
|
|
__trans('Database Patches Pending'),
|
|
[[
|
|
'link' => $this->di['url']->adminLink('system/update'),
|
|
'text' => __trans('Apply Patches'),
|
|
'type' => 'warning',
|
|
]]
|
|
);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->di['logger']->error($e->getMessage());
|
|
}
|
|
|
|
if (Environment::isProduction()) {
|
|
$last_exec = $this->getParamValue('last_cron_exec');
|
|
$cronUrl = $this->di['url']->adminLink('extension/settings/cron');
|
|
|
|
if (!$last_exec) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'danger',
|
|
__trans('Cron was never executed, please ensure you have configured the cronjob or else scheduled tasks within FOSSBilling will not behave correctly.'),
|
|
buttons: [[
|
|
'link' => $cronUrl,
|
|
'text' => __trans('Open Cron Settings'),
|
|
'type' => 'danger',
|
|
]]
|
|
);
|
|
} elseif ((time() - strtotime((string) $last_exec)) / 60 >= 15) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'danger',
|
|
__trans("FOSSBilling has detected that cron hasn't been run in an abnormal time period. Please ensure the cronjob is configured to be run every 5 minutes."),
|
|
buttons: [[
|
|
'link' => $cronUrl,
|
|
'text' => __trans('Open Cron Settings'),
|
|
'type' => 'danger',
|
|
]]
|
|
);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* The below logic is to help ensure that we nudge the user when needed about error reporting.
|
|
*/
|
|
if (Environment::isProduction()) {
|
|
// Get the last time we've nudged the user about error reporting
|
|
$lastErrorReportingNudge = $this->getParamValue('last_error_reporting_nudge');
|
|
|
|
$result = $this->di['cache']->get('error_reporting_nudge', function (ItemInterface $item) use ($lastErrorReportingNudge) {
|
|
$item->expiresAfter(15 * 60);
|
|
$url = $this->di['url']->adminLink('extension/settings/system');
|
|
$this->setParamValue('last_error_reporting_nudge', Version::VERSION);
|
|
|
|
if (!$lastErrorReportingNudge) {
|
|
// The user upgraded from a version that didn't have error reporting functionality, so let's nudge them about it now.
|
|
return [
|
|
'text' => __trans("We'd appreciate it if you'd consider opting into error reporting for FOSSBilling. Doing so will help us improve the software and provide you with a better experience. (Message will remain for 15 minutes)"),
|
|
'url' => $url,
|
|
];
|
|
} elseif ((version_compare(SentryHelper::last_change, $lastErrorReportingNudge) === 1) && Config::getProperty('debug_and_monitoring.report_errors', false) && !Version::isPreviewVersion()) {
|
|
/*
|
|
* The installation already had error reporting enabled, but something has changed so we should nudge the user to review the changes.
|
|
* This message is cached for a full 24 hours to help ensure it is seen.
|
|
*/
|
|
$item->expiresAfter(60 * 60 * 24);
|
|
|
|
return [
|
|
'text' => __trans("Error reporting in FOSSBilling has changed since you last reviewed it. You may want to consider reviewing the changes to see what's been changed. (This message will remain for 24 hours)"),
|
|
'url' => $url,
|
|
];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
if ($result) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'info',
|
|
$result['text'],
|
|
buttons: [[
|
|
'link' => $result['url'],
|
|
'text' => __trans('Review Settings'),
|
|
'type' => 'primary',
|
|
]]
|
|
);
|
|
}
|
|
}
|
|
|
|
$install = Path::join(PATH_ROOT, 'install');
|
|
if ($this->filesystem->exists($install)) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'danger',
|
|
__trans('Installer (":path") still exists. Please remove it for security reasons.', [':path' => $install])
|
|
);
|
|
}
|
|
|
|
if (!extension_loaded('openssl')) {
|
|
$messages[] = $this->createAdminAlert(
|
|
'warning',
|
|
__trans('FOSSBilling requires :extension extension to be enabled on this server for security reasons.', [':extension' => 'php openssl'])
|
|
);
|
|
}
|
|
|
|
try {
|
|
$emailService = $this->di['mod_service']('email');
|
|
$brokenTemplates = $emailService->getBrokenTemplateCount();
|
|
if ($brokenTemplates > 0) {
|
|
$emailSettingsUrl = $this->di['url']->adminLink('extension/settings/email');
|
|
$messages[] = $this->createAdminAlert(
|
|
'warning',
|
|
__trans(':count email template(s) have syntax errors and cannot send emails. Please review and fix them.', [':count' => $brokenTemplates]),
|
|
__trans('Broken Email Templates'),
|
|
[[
|
|
'link' => $emailSettingsUrl,
|
|
'text' => __trans('View Email Templates'),
|
|
'type' => 'warning',
|
|
]]
|
|
);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->di['logger']->error($e->getMessage());
|
|
}
|
|
|
|
if ($type === null || $type === '') {
|
|
return $messages;
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$messages,
|
|
static fn (array $message): bool => ($message['type'] ?? null) === $type
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Get the Central Alerts System messages sent to this installation.
|
|
*
|
|
* @return array - array of messages
|
|
*/
|
|
public function getCasMessages(): array
|
|
{
|
|
try {
|
|
return $this->di['central_alerts']->filterAlerts();
|
|
} catch (\FOSSBilling\Exception $e) {
|
|
return [
|
|
$this->createAdminAlert('warning', $e->getMessage()),
|
|
];
|
|
}
|
|
}
|
|
|
|
public function templateExists(string $file, ?\Box\Mod\Staff\Entity\Admin $identity = null): bool
|
|
{
|
|
$file = trim($file);
|
|
if ($file === '' || str_contains($file, "\0") || Path::isAbsolute($file) || str_contains($file, '..') || str_contains($file, '\\')) {
|
|
return false;
|
|
}
|
|
if (!preg_match('/\A[a-zA-Z0-9_\-\/]+\.html\.twig\z/', $file)) {
|
|
return false;
|
|
}
|
|
|
|
if ($identity instanceof \Box\Mod\Staff\Entity\Admin) {
|
|
$client = false;
|
|
} else {
|
|
$client = true;
|
|
}
|
|
$themeService = $this->di['mod_service']('theme');
|
|
$theme = $themeService->getThemeConfig($client);
|
|
foreach ($theme['paths'] as $path) {
|
|
$candidate = Path::join($path, $file);
|
|
$canonicalBase = Path::canonicalize($path);
|
|
$canonicalCandidate = Path::canonicalize($candidate);
|
|
if (!Path::isBasePath($canonicalBase, $canonicalCandidate)) {
|
|
continue;
|
|
}
|
|
if ($this->filesystem->exists($canonicalCandidate)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function renderAdapterTplString(string $tpl, array $vars): string
|
|
{
|
|
$twigFactory = $this->di['twig_factory'];
|
|
$twig = $twigFactory->createAdapterEnvironment();
|
|
|
|
$rendered = SandboxedStringRenderer::render(
|
|
$twig,
|
|
$tpl,
|
|
$vars,
|
|
'Payment adapter template',
|
|
function (\Twig\Sandbox\SecurityError $e): void {
|
|
$this->di['logger']->withChannel('security')->warning('Payment adapter template sandbox violation', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
);
|
|
|
|
return BrowserHtmlSanitizer::sanitizeAdapterHtml($rendered);
|
|
}
|
|
|
|
/**
|
|
* Render a template string using the sandboxed email Twig environment.
|
|
* Use this for database-stored templates (email templates, mass mailer).
|
|
*
|
|
* @param string $tpl The template string to render
|
|
* @param array $vars Variables to pass to the template
|
|
* @param string|null $timezone Optional IANA timezone for date formatting;
|
|
* pass the recipient's so emails render in
|
|
* their local time. Falls back to the active
|
|
* user's timezone, then the config.
|
|
* @param string|null $locale Optional ICU locale for date formatting;
|
|
* pass the recipient's language so emails
|
|
* render in their language. Outranks browser
|
|
* auto-detection. Falls back to the active
|
|
* locale, then the config.
|
|
*
|
|
* @return string The rendered template
|
|
*
|
|
* @throws \FOSSBilling\InformationException If template violates sandbox policy or has syntax errors
|
|
*/
|
|
public function renderEmailTplString(string $tpl, array $vars, ?string $timezone = null, ?string $locale = null): string
|
|
{
|
|
$twigFactory = $this->di['twig_factory'];
|
|
$twig = $twigFactory->createEmailEnvironment($timezone, $locale);
|
|
|
|
return SandboxedStringRenderer::render(
|
|
$twig,
|
|
$tpl,
|
|
$vars,
|
|
'Email template',
|
|
function (\Twig\Sandbox\SecurityError $e): void {
|
|
$this->di['logger']->withChannel('security')->warning('Email template sandbox violation', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render an email subject line (plaintext header) through the
|
|
* HTML-autoescaping email environment, decoding once to restore it as
|
|
* typed. Never use this for the HTML body.
|
|
*/
|
|
public function renderEmailSubjectString(string $tpl, array $vars, ?string $timezone = null, ?string $locale = null): string
|
|
{
|
|
$rendered = $this->renderEmailTplString($tpl, $vars, $timezone, $locale);
|
|
|
|
return html_entity_decode($rendered, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
public function checkEmailTplSyntax(string $tpl): void
|
|
{
|
|
$twigFactory = $this->di['twig_factory'];
|
|
$twig = $twigFactory->createEmailEnvironment();
|
|
|
|
try {
|
|
$stream = $twig->tokenize(new \Twig\Source($tpl, '__validation__'));
|
|
$twig->parse($stream);
|
|
} catch (\Twig\Error\SyntaxError $e) {
|
|
throw new \FOSSBilling\InformationException('Email template syntax error: ' . $e->getMessage());
|
|
} catch (\Twig\Sandbox\SecurityError $e) {
|
|
throw new \FOSSBilling\InformationException('Email template contains disallowed Twig syntax: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public function clearCache(?string $cachePath = null): bool
|
|
{
|
|
$path = $cachePath ?? PATH_CACHE;
|
|
$this->filesystem->remove($path);
|
|
$this->filesystem->mkdir($path);
|
|
|
|
// Also flush the configured application/rate-limiter/Doctrine cache pools, which may be
|
|
// backed by Redis or Memcached rather than the filesystem path cleared above.
|
|
CacheFactory::clearAll();
|
|
|
|
// clearAll() above only reaches CacheFactory::NAMESPACE_DOCTRINE's own bare namespace -
|
|
// EntityManagerFactory actually stores the Doctrine metadata/query/result cache under a
|
|
// namespace hashed from the current entity files' mtimes/sizes (so it self-invalidates on
|
|
// an entity change without needing this call at all), which clearAll()'s fixed namespace
|
|
// list can never know to include. Harmless to skip on the filesystem driver (the
|
|
// remove()/mkdir() above already covers it), but a Redis/Memcached-backed pool has no
|
|
// other way to ever be reached. clearNamespace() is best-effort, same as clearAll() itself
|
|
// above - a cache-backend hiccup here must not make clearCache() report failure when the
|
|
// filesystem cache was already cleared fine.
|
|
CacheFactory::clearNamespace(EntityManagerFactory::metadataCacheNamespace());
|
|
|
|
return true;
|
|
}
|
|
|
|
public function getEnv(bool $fetchExternalIp = false)
|
|
{
|
|
if ($fetchExternalIp) {
|
|
try {
|
|
return $this->di['tools']->getExternalIP();
|
|
} catch (\Exception) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
$r = new \FOSSBilling\Requirements();
|
|
$data = $r->checkCompat();
|
|
$data['last_patch'] = $this->getParamValue('last_patch');
|
|
|
|
return $data;
|
|
}
|
|
|
|
public function getCurrentUrl(): string
|
|
{
|
|
$request = $this->di['request'];
|
|
|
|
return $request->getSchemeAndHttpHost() . strtok($request->getRequestUri(), '?');
|
|
}
|
|
|
|
public function getPeriod($code)
|
|
{
|
|
if (!is_scalar($code)) {
|
|
return '-';
|
|
}
|
|
|
|
$code = (string) $code;
|
|
if ($code === '' || $code === '0') {
|
|
return '-';
|
|
}
|
|
|
|
$p = Period::getPredefined();
|
|
if (isset($p[$code])) {
|
|
return $p[$code];
|
|
}
|
|
|
|
$p = new Period($code);
|
|
|
|
return $p->getTitle();
|
|
}
|
|
|
|
public function getPublicParamValue($param): ?string
|
|
{
|
|
$setting = $this->settingRepository->findOnePublicByParam((string) $param);
|
|
if ($setting === null) {
|
|
throw new \FOSSBilling\Exception('Parameter :param does not exist', [':param' => $param]);
|
|
}
|
|
|
|
return $setting->getValue();
|
|
}
|
|
|
|
/**
|
|
* @return mixed[]
|
|
*/
|
|
public function getNameservers(): array
|
|
{
|
|
$settings = $this->settingRepository->findByParams(['nameserver_1', 'nameserver_2', 'nameserver_3', 'nameserver_4']);
|
|
$result = [];
|
|
foreach ($settings as $setting) {
|
|
$result[(string) $setting->getParam()] = $setting->getValue();
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function getPendingMessages()
|
|
{
|
|
$messages = $this->di['session']->get('pending_messages');
|
|
|
|
if (!is_array($messages)) {
|
|
return [];
|
|
}
|
|
|
|
return $messages;
|
|
}
|
|
|
|
public function setPendingMessage($msg): bool
|
|
{
|
|
$messages = $this->getPendingMessages();
|
|
$messages[] = $msg;
|
|
$this->di['session']->set('pending_messages', $messages);
|
|
|
|
return true;
|
|
}
|
|
|
|
public function clearPendingMessages(): bool
|
|
{
|
|
$this->di['session']->delete('pending_messages');
|
|
|
|
return true;
|
|
}
|
|
|
|
#[AsEventListener]
|
|
public function refreshGeoIpAndPruneCache(BeforeAdminCronRunEvent $event): void
|
|
{
|
|
/** @var Reader $geoipReader */
|
|
$geoipReader = (new \ReflectionClass(Reader::class))->newInstanceWithoutConstructor();
|
|
$geoipReader->setDi($this->di);
|
|
$geoipReader->updateDefaultDatabases();
|
|
|
|
try {
|
|
// Prune the cache. Only filesystem-backed pools support this; Redis/Memcached
|
|
// expire entries on their own and don't implement PruneableInterface.
|
|
$cache = $this->di['cache'];
|
|
if ($cache instanceof \Symfony\Component\Cache\PruneableInterface && $cache->prune()) {
|
|
$this->di['logger']->withChannel('cron')->info('Pruned the filesystem cache');
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->di['logger']->error($e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Claims a numeric counter setting's current value and advances it, under a row lock so two
|
|
* concurrent callers are never handed the same number. Returns null if it is missing or not
|
|
* numeric and no $seed was given to repair it with.
|
|
*
|
|
* A $seed is only used if the setting is still missing or invalid once the lock is held, so
|
|
* concurrent callers seeding the same value cannot both reserve it. If they collide on
|
|
* creating the row, the loser reserves from the winner's row instead.
|
|
*
|
|
* Not subject to canUpdateParam(): this reserves an internal counter rather than applying a
|
|
* user-driven settings change, and must work in client and cron contexts.
|
|
*
|
|
* The reservation nests via SAVEPOINT when the caller already holds a transaction on the
|
|
* shared connection (which is always the case for the invoice issuance path), so an outer
|
|
* rollback undoes the advance as well: a failed issuance burns no number and leaves no gap.
|
|
* Pinned by ReserveNumericParamValueConcurrencyTest's rollback case.
|
|
*
|
|
* Callers should invoke this before doing any of their own reads on the shared connection,
|
|
* not after. On SQLite, an outer transaction that already read something is holding a SHARED
|
|
* lock the whole time this method runs; if a competing writer is holding RESERVED when this
|
|
* method's own lock-escalating write attempts to upgrade that SHARED lock, the outer
|
|
* transaction cannot release just its read - only rolling back the whole thing would - so
|
|
* sustained contention can exhaust every retry with the outer transaction still open. This
|
|
* doesn't apply to the getNextInvoiceNumber() caller today (it calls this before its own
|
|
* flush() opens any transaction), but would matter for a future caller that reserves from
|
|
* partway through an already-open transaction.
|
|
*/
|
|
public function reserveNextNumericParamValue(string $param, ?int $seed = null): ?int
|
|
{
|
|
if (empty($param)) {
|
|
throw new \FOSSBilling\Exception('Parameter key is missing.');
|
|
}
|
|
|
|
// On MySQL/PostgreSQL this only ever needs the single, unconditional retry below: two
|
|
// callers seeding a counter that does not exist yet both pass the locking read (InnoDB gap
|
|
// locks are shared, so neither blocks the other) and collide on the insert instead - the
|
|
// loser's transaction rolls back, and its one retry finds the winner's row already there,
|
|
// so there's no further contention to loop on.
|
|
//
|
|
// SQLite is different: DriverManagerFactory's pdo_sqlite connection sets no busy timeout,
|
|
// so doReserveNumericParamValue()'s lock-escalating write fails outright (non-blocking)
|
|
// rather than waiting, surfacing here as LockWaitTimeoutException - the same as MySQL's
|
|
// gap-lock collision from this method's point of view, but for a different reason. With
|
|
// only two or three concurrent SQLite writers this is still typically a one-shot race the
|
|
// single retry resolves, but with more than two, the retry itself can collide again (all
|
|
// attempts landing at roughly the same instant, not staggered) - so this loops with a short
|
|
// random backoff between attempts, up to a small bound, rather than assuming one retry is
|
|
// always enough on every driver.
|
|
$attempts = 0;
|
|
while (true) {
|
|
try {
|
|
return $this->reserveNumericParamValue($param, $seed);
|
|
} catch (UniqueConstraintViolationException|DeadlockException|LockWaitTimeoutException $e) {
|
|
if (++$attempts >= 5) {
|
|
throw $e;
|
|
}
|
|
|
|
usleep(random_int(5_000, 25_000));
|
|
}
|
|
}
|
|
}
|
|
|
|
private function reserveNumericParamValue(string $param, ?int $seed): ?int
|
|
{
|
|
/** @var Connection $connection */
|
|
$connection = $this->di['dbal'];
|
|
|
|
try {
|
|
return $connection->transactional(fn (Connection $connection): ?int => $this->doReserveNumericParamValue($connection, $param, $seed));
|
|
} finally {
|
|
$this->settingRepository->clearRequestCache();
|
|
}
|
|
}
|
|
|
|
private function doReserveNumericParamValue(Connection $connection, string $param, ?int $seed): ?int
|
|
{
|
|
// RowLock::suffix() appends `FOR UPDATE` on MySQL/PostgreSQL, taking the write lock at
|
|
// the SELECT below. SQLite has no such clause, and a plain deferred transaction - whether
|
|
// opened at the top level via BEGIN, or nested via SAVEPOINT when $connection already has
|
|
// an outer transaction open - takes no lock at all until the first write. Two concurrent
|
|
// transactions could otherwise both pass the read under a shared lock before either takes
|
|
// a write lock, and the second writer - having cached a stale $current - would silently
|
|
// write the same value the first writer already committed.
|
|
//
|
|
// SQLite escalates a transaction's lock to RESERVED on its first write statement,
|
|
// regardless of nesting depth: a SAVEPOINT doesn't get its own independent lock, it
|
|
// shares the one connection-wide transaction's lock state. Issuing a real write - even
|
|
// this no-op UPDATE, which affects zero rows whenever the counter doesn't exist yet -
|
|
// before the read below forces that escalation immediately and uniformly, whether or not
|
|
// there's an open outer transaction, so only one caller can ever be mid-reservation at a
|
|
// time. At most one connection can hold RESERVED simultaneously, so a second writer's own
|
|
// attempt at this same statement blocks (or fails outright, with SQLite's non-blocking
|
|
// busy behaviour) until the first has committed or rolled back. Verified against real
|
|
// SQLite for both the top-level and nested cases - see
|
|
// ReserveNumericParamValueConcurrencyTest.php.
|
|
if ($connection->getDatabasePlatform() instanceof SQLitePlatform) {
|
|
$connection->executeStatement(
|
|
'UPDATE setting SET updated_at = updated_at WHERE param = :param',
|
|
['param' => $param]
|
|
);
|
|
}
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
$current = $connection->fetchOne(
|
|
'SELECT value FROM setting WHERE param = :param' . RowLock::suffix($connection),
|
|
['param' => $param]
|
|
);
|
|
|
|
$exists = $current !== false;
|
|
if (!$exists || filter_var($current, FILTER_VALIDATE_INT) === false) {
|
|
if ($seed === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($exists) {
|
|
$connection->executeStatement(
|
|
'UPDATE setting SET value = :value, updated_at = :updated_at WHERE param = :param',
|
|
['value' => (string) ($seed + 1), 'updated_at' => $now, 'param' => $param]
|
|
);
|
|
} else {
|
|
$connection->executeStatement(
|
|
'INSERT INTO setting (param, value, created_at, updated_at) VALUES (:param, :value, :created_at, :updated_at)',
|
|
['param' => $param, 'value' => (string) ($seed + 1), 'created_at' => $now, 'updated_at' => $now]
|
|
);
|
|
}
|
|
|
|
return $seed;
|
|
}
|
|
|
|
$connection->executeStatement(
|
|
'UPDATE setting SET value = :value, updated_at = :updated_at WHERE param = :param',
|
|
['value' => (string) (intval($current) + 1), 'updated_at' => $now, 'param' => $param]
|
|
);
|
|
|
|
return intval($current);
|
|
}
|
|
|
|
private function canUpdateParam(string $param): bool
|
|
{
|
|
// Compare case-insensitively so the check agrees with the lookup,
|
|
// which resolves case-insensitively on some database drivers.
|
|
$param = strtolower($param);
|
|
|
|
$company = [
|
|
'company_name',
|
|
'company_email',
|
|
'company_tel',
|
|
'company_address_1',
|
|
'company_address_2',
|
|
'company_address_3',
|
|
'company_logo',
|
|
'company_logo_dark',
|
|
'company_favicon',
|
|
'company_number',
|
|
'company_vat_number',
|
|
'company_account_number',
|
|
'company_bank_name',
|
|
'company_bic',
|
|
'company_display_bank_info',
|
|
'company_bank_info_pagebottom',
|
|
'hide_company_public',
|
|
'company_signature',
|
|
];
|
|
$company_legal = ['company_tos', 'company_privacy_policy', 'company_note'];
|
|
|
|
$staff_service = $this->di['mod_service']('Staff');
|
|
if (in_array($param, $company) && !$staff_service->hasPermission(null, 'system', 'manage_company_details')) {
|
|
return false;
|
|
}
|
|
|
|
if (in_array($param, $company_legal) && !$staff_service->hasPermission(null, 'system', 'manage_company_legal')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|