Merge branch 'feat/backup' into nightly

This commit is contained in:
FreddleSpl0it committed 2026-11-09 15:50:43 +01:00
commit aededa7ade
9 files changed
+39 -5

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ $template_data = [
'mbox_templates' => $mbox_templates, 'mbox_templates' => $mbox_templates,
'backup_timezone' => getenv('TZ') ?: 'UTC', 'backup_timezone' => getenv('TZ') ?: 'UTC',
'backup_hostname' => getenv('MAILCOW_HOSTNAME') ?: '', 'backup_hostname' => getenv('MAILCOW_HOSTNAME') ?: '',
'backup_demo_mode' => $DEMO_MODE,
'lang_admin' => json_encode($lang['admin']), 'lang_admin' => json_encode($lang['admin']),
'lang_datatables' => json_encode($lang['datatables']) 'lang_datatables' => json_encode($lang['datatables'])
]; ];
@@ -39,7 +39,8 @@ use Redis;
* process_get_return(BackupModule::fromGlobals()->api->getSettings()); * process_get_return(BackupModule::fromGlobals()->api->getSettings());
* echo json_encode(BackupModule::create($pdo, Context::system(), $config, $redis)->api->export($domain, $page)); // backup.php * echo json_encode(BackupModule::create($pdo, Context::system(), $config, $redis)->api->export($domain, $page)); // backup.php
* *
* $config: timezone (TZ of mailcow, for the schedule), alias_disable_external_domains ($ALIAS_DISABLE_EXTERNAL_DOMAINS). * $config: timezone (TZ of mailcow, for the schedule), alias_disable_external_domains ($ALIAS_DISABLE_EXTERNAL_DOMAINS),
* demo_mode ($DEMO_MODE: the API changes nothing).
* $redis: for DKIM keys, rate limits and the domain map (export and restore). * $redis: for DKIM keys, rate limits and the domain map (export and restore).
*/ */
class BackupModule class BackupModule
@@ -73,15 +74,17 @@ class BackupModule
$restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false)); $restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false));
$domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy); $domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy);
return new self($settings, $jobs, $export, $restore, $domainRestore, return new self($settings, $jobs, $export, $restore, $domainRestore,
new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS))); new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS),
(bool)($config['demo_mode'] ?? false)));
} }
/** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ and $ALIAS_DISABLE_EXTERNAL_DOMAINS */ /** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ, $ALIAS_DISABLE_EXTERNAL_DOMAINS and $DEMO_MODE */
public static function fromGlobals(?Context $context = null): self public static function fromGlobals(?Context $context = null): self
{ {
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [ return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
'timezone' => getenv('TZ') ?: 'UTC', 'timezone' => getenv('TZ') ?: 'UTC',
'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true, 'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true,
], ($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null); 'demo_mode' => ($GLOBALS['DEMO_MODE'] ?? false) === true,
],($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null);
} }
} }
@@ -23,7 +23,8 @@ use PDOException;
class BackupApiController class BackupApiController
{ {
public function __construct(private SettingsService $settings, private JobService $jobs, private ExportService $export, public function __construct(private SettingsService $settings, private JobService $jobs, private ExportService $export,
private RestoreService $restore, private DomainRestoreService $domainRestore, private ApiResponse $response) private RestoreService $restore, private DomainRestoreService $domainRestore, private ApiResponse $response,
private bool $demoMode = false)
{ {
} }
@@ -147,11 +148,15 @@ class BackupApiController
/** /**
* Runs one write call: $work gets the log entry and returns the result for process_*_return(). * Runs one write call: $work gets the log entry and returns the result for process_*_return().
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
* In demo mode no write call runs.
*/ */
private function handle(string $action, string $object, mixed $data, callable $work): bool private function handle(string $action, string $object, mixed $data, callable $work): bool
{ {
$log = ['backup', $action, $object, $data]; $log = ['backup', $action, $object, $data];
try { try {
if ($this->demoMode) {
throw new UserError('demo_mode_enabled');
}
$result = $work($log); $result = $work($log);
} catch (UserError $e) { } catch (UserError $e) {
$result = $this->response->error($e, $log); $result = $this->response->error($e, $log);
+1
View File
@@ -13,6 +13,7 @@ $template_data = [
'lang_datatables' => json_encode($lang['datatables']), 'lang_datatables' => json_encode($lang['datatables']),
'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(), 'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(),
'backup_timezone' => getenv('TZ') ?: 'UTC', 'backup_timezone' => getenv('TZ') ?: 'UTC',
'backup_demo_mode' => $DEMO_MODE,
]; ];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
+1
View File
@@ -607,6 +607,7 @@
"created_from_backup": "Aus dem Backup angelegt", "created_from_backup": "Aus dem Backup angelegt",
"data_added": "Hinzugefügt", "data_added": "Hinzugefügt",
"deleted_domain_children": "Die Domain wurde gelöscht: Nur die ganze Domain lässt sich wiederherstellen.", "deleted_domain_children": "Die Domain wurde gelöscht: Nur die ganze Domain lässt sich wiederherstellen.",
"demo_mode": "Demo Mode ist aktiviert: Backups, Wiederherstellungen und Änderungen an den Einstellungen sind gesperrt",
"details": "Details", "details": "Details",
"domains": "Domains", "domains": "Domains",
"duration": "Dauer", "duration": "Dauer",
+1
View File
@@ -607,6 +607,7 @@
"created_from_backup": "Created from the backup", "created_from_backup": "Created from the backup",
"data_added": "Added", "data_added": "Added",
"deleted_domain_children": "The domain was deleted: only the whole domain can be restored.", "deleted_domain_children": "The domain was deleted: only the whole domain can be restored.",
"demo_mode": "Demo Mode is enabled: backups, restores and changes to the settings are disabled",
"details": "Details", "details": "Details",
"domains": "Domains", "domains": "Domains",
"duration": "Duration", "duration": "Duration",
@@ -13,6 +13,7 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col-sm-9 offset-sm-3"> <div class="col-sm-9 offset-sm-3">
<p class="text-muted">{{ lang.backup.intro }} <a href="/admin/restore">{{ lang.backup.intro_restore_link }}</a></p> <p class="text-muted">{{ lang.backup.intro }} <a href="/admin/restore">{{ lang.backup.intro_restore_link }}</a></p>
{% if backup_demo_mode %}<div class="alert alert-warning">{{ lang.backup.demo_mode }}</div>{% endif %}
<div id="backup_status"> <div id="backup_status">
<div class="backup-status-summary mb-2" aria-live="polite"><span class="text-muted">{{ lang.footer.loading }}</span></div> <div class="backup-status-summary mb-2" aria-live="polite"><span class="text-muted">{{ lang.footer.loading }}</span></div>
+1
View File
@@ -10,6 +10,7 @@
<span>{{ lang.backup.restore_card }}</span> <span>{{ lang.backup.restore_card }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if backup_demo_mode %}<div class="alert alert-warning">{{ lang.backup.demo_mode }}</div>{% endif %}
<div class="backup-restore-hints"></div> <div class="backup-restore-hints"></div>
{# left-aligned with the search of the toolbar below #} {# left-aligned with the search of the toolbar below #}
<div class="d-flex flex-wrap align-items-center gap-2 mb-3 px-2"> <div class="d-flex flex-wrap align-items-center gap-2 mb-3 px-2">
@@ -81,6 +81,26 @@ class BackupApiControllerTest extends TestCase
$this->assertSame(['backup', 'delete', 'job', ['id' => [4, '7', 'x']]], $_SESSION['return'][4]['log']); $this->assertSame(['backup', 'delete', 'job', ['id' => [4, '7', 'x']]], $_SESSION['return'][4]['log']);
} }
public function testDemoModeChangesNothing(): void
{
$this->settings->expects($this->never())->method('save');
foreach (['create', 'restore', 'cancel'] as $method) {
$this->jobs->expects($this->never())->method($method);
}
$this->jobs->method('status')->willReturn(['configured' => false]);
$api = new BackupApiController($this->settings, $this->jobs, $this->createMock(ExportService::class), $this->restore,
$this->domainRestore, new ApiResponse(Settings::SECRETS), true);
$this->assertFalse($api->editSettings(['target' => 's3', 's3_endpoint' => 'https://s3.example.org']));
$this->assertFalse($api->addJob(['type' => 'backup']));
$this->assertFalse($api->addRestore(['snapshot' => 'abc']));
$this->assertFalse($api->deleteJobs([4]));
$this->assertSame(array_fill(0, 4, ['danger', 'demo_mode_enabled']),
array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return']));
$this->assertSame(['configured' => false], $api->getStatus());
}
public function testReads(): void public function testReads(): void
{ {
$this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => ($id === 4) ? self::job(4, 'backup') : null); $this->jobs->method('get')->willReturnCallback(fn(int $id): ?Job => ($id === 4) ? self::job(4, 'backup') : null);