mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-10 10:31:01 +00:00
Merge branch 'feat/backup' into nightly
This commit is contained in:
66 files changed
+15359
-97
No files matched your search
@@ -76,6 +76,7 @@ $globalVariables = [
|
||||
'lang_groups' => json_encode($lang['groups']),
|
||||
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
|
||||
'lang_retention' => json_encode($lang['retention'] ?? []),
|
||||
'lang_backup' => json_encode($lang['backup'] ?? []),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -682,6 +682,67 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"backup_setting" => array(
|
||||
"cols" => array(
|
||||
"name" => "VARCHAR(64) NOT NULL",
|
||||
"value" => "TEXT NOT NULL",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("name")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"backup_job" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"type" => "ENUM('backup', 'check', 'prune', 'init', 'restore') NOT NULL",
|
||||
"state" => "ENUM('queued', 'running', 'done', 'failed', 'cancelled') NOT NULL DEFAULT 'queued'",
|
||||
"step" => "VARCHAR(32) NULL DEFAULT NULL",
|
||||
"params" => "MEDIUMTEXT NULL DEFAULT NULL",
|
||||
"result" => "MEDIUMTEXT NULL DEFAULT NULL",
|
||||
"log" => "MEDIUMTEXT NULL DEFAULT NULL",
|
||||
"error" => "TEXT NULL DEFAULT NULL",
|
||||
"domain" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"requested_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"started" => "DATETIME NULL DEFAULT NULL",
|
||||
"finished" => "DATETIME NULL DEFAULT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"state_type" => array("state", "type"),
|
||||
"domain_created" => array("domain", "created")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"backup_snapshot" => array(
|
||||
"cols" => array(
|
||||
"id" => "VARCHAR(64) NOT NULL",
|
||||
"short_id" => "VARCHAR(16) NOT NULL",
|
||||
"time" => "DATETIME NOT NULL",
|
||||
"hostname" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"tags" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"summary" => "TEXT NULL DEFAULT NULL",
|
||||
"domains" => "MEDIUMTEXT NULL DEFAULT NULL",
|
||||
"content" => "MEDIUMBLOB NULL DEFAULT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"time" => array("time")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"mailbox_group_imap_grant" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup;
|
||||
|
||||
use Mailcow\Backup\Controller\BackupApiController;
|
||||
use Mailcow\Backup\Legacy\LegacyDomain;
|
||||
use Mailcow\Backup\Legacy\NoTemplates;
|
||||
use Mailcow\Backup\Model\Settings;
|
||||
use Mailcow\Backup\Repository\ExportRepository;
|
||||
use Mailcow\Backup\Repository\JobRepository;
|
||||
use Mailcow\Backup\Repository\RestoreRepository;
|
||||
use Mailcow\Backup\Repository\SettingsRepository;
|
||||
use Mailcow\Backup\Repository\SnapshotRepository;
|
||||
use Mailcow\Backup\Service\DomainRestoreService;
|
||||
use Mailcow\Backup\Service\ExportService;
|
||||
use Mailcow\Backup\Service\GroupMembers;
|
||||
use Mailcow\Backup\Service\JobService;
|
||||
use Mailcow\Backup\Service\RestoreService;
|
||||
use Mailcow\Backup\Service\SettingsService;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Dovecot\DovecotModule;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
use Mailcow\MailboxGroup\Service\ChangeEffects;
|
||||
use Mailcow\MailboxGroup\Service\SharingService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
use Mailcow\Sogo\SogoModule;
|
||||
use PDO;
|
||||
use Redis;
|
||||
|
||||
/**
|
||||
* Composition root of the backup with restic: settings, jobs of the runners, snapshots, the export of a domain and the
|
||||
* restore of mailboxes and domains. The runner in the restic container reaches the export and the restore actions
|
||||
* through data/conf/rspamd/dynmaps/backup.php.
|
||||
*
|
||||
* process_get_return(BackupModule::fromGlobals()->api->getSettings());
|
||||
* 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).
|
||||
* $redis: for DKIM keys, rate limits and the domain map (export and restore).
|
||||
*/
|
||||
class BackupModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly SettingsService $settings,
|
||||
public readonly JobService $jobs,
|
||||
public readonly ExportService $export,
|
||||
public readonly RestoreService $restore,
|
||||
public readonly DomainRestoreService $domainRestore,
|
||||
public readonly BackupApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context, array $config = [], ?Redis $redis = null): self
|
||||
{
|
||||
$jobRepo = new JobRepository($pdo);
|
||||
$restoreRepo = new RestoreRepository($pdo);
|
||||
$legacy = new LegacyDomain($redis);
|
||||
$settings = new SettingsService(new SettingsRepository($pdo), $context);
|
||||
$jobs = new JobService($jobRepo, new SnapshotRepository($pdo), $settings, $context, (string)($config['timezone'] ?? 'UTC'));
|
||||
$export = new ExportService(new ExportRepository($pdo), $legacy);
|
||||
$sogo = SogoModule::create()->sogo;
|
||||
$dovecot = DovecotModule::create()->dovecot;
|
||||
// the group module without group templates: a restored mailbox keeps the settings of the backup
|
||||
$groupRepo = new GroupRepository($pdo);
|
||||
$lookup = new LookupRepository($pdo);
|
||||
$templates = new TemplateService($lookup, new NoTemplates());
|
||||
$effects = new ChangeEffects($templates, $sogo, $groupRepo, $dovecot);
|
||||
$groups = new GroupMembers($groupRepo, $lookup, $templates, $effects, new SharingService($groupRepo, $dovecot, $sogo, $effects));
|
||||
$restore = new RestoreService($restoreRepo, $jobRepo, $groups, $sogo, $dovecot, $legacy, (bool)($config['alias_disable_external_domains'] ?? false));
|
||||
$domainRestore = new DomainRestoreService($restoreRepo, $jobRepo, $groups, $legacy);
|
||||
return new self($settings, $jobs, $export, $restore, $domainRestore,
|
||||
new BackupApiController($settings, $jobs, $export, $restore, $domainRestore, new ApiResponse(Settings::SECRETS)));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo, $redis, $_SESSION, TZ and $ALIAS_DISABLE_EXTERNAL_DOMAINS */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
|
||||
'timezone' => getenv('TZ') ?: 'UTC',
|
||||
'alias_disable_external_domains' => ($GLOBALS['ALIAS_DISABLE_EXTERNAL_DOMAINS'] ?? false) === true,
|
||||
], ($GLOBALS['redis'] ?? null) instanceof Redis ? $GLOBALS['redis'] : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Controller;
|
||||
|
||||
use Mailcow\Backup\Model\Job;
|
||||
use Mailcow\Backup\Service\DomainRestoreService;
|
||||
use Mailcow\Backup\Service\ExportService;
|
||||
use Mailcow\Backup\Service\JobService;
|
||||
use Mailcow\Backup\Service\RestoreService;
|
||||
use Mailcow\Backup\Service\SettingsService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php (routes backup-settings, backup-status, backup-snapshots, backup-snapshot-content,
|
||||
* backup-jobs, backup-job, backup-restore) and the runner's endpoint data/conf/rspamd/dynmaps/backup.php (domains,
|
||||
* export pages, restore actions). Messages are logged as ['backup', action, object, data].
|
||||
*/
|
||||
class BackupApiController
|
||||
{
|
||||
public function __construct(private SettingsService $settings, private JobService $jobs, private ExportService $export,
|
||||
private RestoreService $restore, private DomainRestoreService $domainRestore, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** GET backup-settings: secrets as <key>_set; {} for non-admins */
|
||||
public function getSettings(): array
|
||||
{
|
||||
return $this->settings->get()?->toArray() ?? [];
|
||||
}
|
||||
|
||||
/** POST edit/backup-settings */
|
||||
public function editSettings(array $attr): bool
|
||||
{
|
||||
return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
|
||||
$this->settings->save($attr);
|
||||
return $this->response->success('backup_settings_saved', $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET backup-status */
|
||||
public function getStatus(): array
|
||||
{
|
||||
return $this->jobs->status();
|
||||
}
|
||||
|
||||
/** GET backup-snapshots */
|
||||
public function getSnapshots(): array
|
||||
{
|
||||
return $this->jobs->snapshots();
|
||||
}
|
||||
|
||||
/** GET backup-snapshot-content/<snapshot>[/<domain>]: domains of a snapshot, or the mailboxes of one, with their state now */
|
||||
public function getSnapshotContent(mixed $snapshot, mixed $domain = null): array
|
||||
{
|
||||
return ($domain === null || $domain === '') ? $this->jobs->content($snapshot) : $this->jobs->domainContent($snapshot, $domain);
|
||||
}
|
||||
|
||||
/** GET backup-snapshot-search/<snapshot>/<term>: domains and mailboxes of a snapshot matching the term */
|
||||
public function getSnapshotSearch(mixed $snapshot, mixed $term): array
|
||||
{
|
||||
return $this->jobs->search($snapshot, $term);
|
||||
}
|
||||
|
||||
/** GET backup-jobs[/<count>[/<type>]]: newest first, without logs; count 0 = default */
|
||||
public function getJobs(int $limit = 0, mixed $type = null): array
|
||||
{
|
||||
return array_map(fn(Job $job): array => $this->jobs->view($job), $this->jobs->list($limit, $type));
|
||||
}
|
||||
|
||||
/** GET backup-job/<id>: with its log */
|
||||
public function getJob(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$job = ($id === null) ? null : $this->jobs->get($id);
|
||||
return ($job === null) ? [] : $this->jobs->view($job, true);
|
||||
}
|
||||
|
||||
/** POST add/backup-job: {type: backup|check|prune|init} */
|
||||
public function addJob(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'job', $attr, function (array $log) use ($attr): bool {
|
||||
$job = $this->jobs->create($attr);
|
||||
return $this->response->success(['backup_job_added', $job->type, $job->id], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST add/backup-restore: {snapshot, scope, mailboxes | domain, components, mail_target} */
|
||||
public function addRestore(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'restore', $attr, function (array $log) use ($attr): bool {
|
||||
$job = $this->jobs->restore($attr);
|
||||
return $this->response->success(['backup_restore_added', $job->id, count($job->mailboxes())], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/backup-job: cancels queued and running jobs, one message per id */
|
||||
public function deleteJobs(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool {
|
||||
foreach ($items as $id) {
|
||||
try {
|
||||
$job = $this->jobs->cancel($this->requireId($id));
|
||||
$this->response->success(['backup_job_cancelled', $job->id], $log);
|
||||
} catch (UserError $e) {
|
||||
$this->response->error($e, $log);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** backup.php ?domains=1 */
|
||||
public function domains(): array
|
||||
{
|
||||
return ['domains' => $this->export->domains()];
|
||||
}
|
||||
|
||||
/** backup.php ?export=<domain>[&after=<username>] @throws UserError */
|
||||
public function export(string $domain, ?string $after = null): array
|
||||
{
|
||||
return $this->export->export($domain, $after);
|
||||
}
|
||||
|
||||
/**
|
||||
* backup.php POST of the runner, {"result": ...} of
|
||||
* {"action": "restore_domain", "job": id, "data": <domain_data>}: creates a deleted domain
|
||||
* {"action": "restore_aliases", "job": id, "aliases": [...]}: a part of the domain's aliases (scope domain)
|
||||
* {"action": "restore", "job": id, "username": u, "data": <mailbox section>}: one mailbox
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function post(array $body): array
|
||||
{
|
||||
return ['result' => match ($body['action'] ?? null) {
|
||||
'restore' => $this->restore->merge($body['job'] ?? null, $body['username'] ?? null, $body['data'] ?? null),
|
||||
'restore_aliases' => $this->restore->mergeAliases($body['job'] ?? null, $body['aliases'] ?? null),
|
||||
'restore_domain' => $this->domainRestore->restore($body['job'] ?? null, $body['data'] ?? null),
|
||||
default => throw new UserError('backup_action_invalid'),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function handle(string $action, string $object, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['backup', $action, $object, $data];
|
||||
try {
|
||||
$result = $work($log);
|
||||
} catch (UserError $e) {
|
||||
$result = $this->response->error($e, $log);
|
||||
} catch (PDOException $e) {
|
||||
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
|
||||
}
|
||||
$this->response->flush();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Integer id from request input ("12", 12); null for anything else */
|
||||
private function toId(mixed $id): ?int
|
||||
{
|
||||
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied for ids that cannot exist */
|
||||
private function requireId(mixed $id): int
|
||||
{
|
||||
return $this->toId($id) ?? throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Legacy;
|
||||
|
||||
use Redis;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* What the legacy code keeps outside the database for domains and mailboxes: DKIM keys, rate limits and the domain
|
||||
* map in Redis (functions.dkim/ratelimit/mailbox.inc.php), and the SOGo restart through dockerapi. Redis and docker()
|
||||
* sit in protected methods so tests can replace them.
|
||||
*/
|
||||
class LegacyDomain
|
||||
{
|
||||
public function __construct(private ?Redis $redis = null)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return ?array{selector: string, pubkey: string, privkey: string} the DKIM key of a domain */
|
||||
public function dkim(string $domain): ?array
|
||||
{
|
||||
$pubkey = $this->redis()->hGet('DKIM_PUB_KEYS', $domain);
|
||||
if (!is_string($pubkey) || $pubkey === '') {
|
||||
return null;
|
||||
}
|
||||
$selector = (string)$this->redis()->hGet('DKIM_SELECTORS', $domain);
|
||||
return ['selector' => $selector, 'pubkey' => $pubkey, 'privkey' => (string)$this->redis()->hGet('DKIM_PRIV_KEYS', $selector . '.' . $domain)];
|
||||
}
|
||||
|
||||
/** Stores a DKIM key like dkim('import') does */
|
||||
public function addDkim(string $domain, string $selector, string $pubkey, string $privkey): void
|
||||
{
|
||||
$this->redis()->hSet('DKIM_PUB_KEYS', $domain, $pubkey);
|
||||
$this->redis()->hSet('DKIM_SELECTORS', $domain, $selector);
|
||||
$this->redis()->hSet('DKIM_PRIV_KEYS', $selector . '.' . $domain, $privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $objects domains or mailboxes
|
||||
* @return array<string, string> object => rate limit ("10 / 1h") of those with one
|
||||
*/
|
||||
public function rateLimits(array $objects): array
|
||||
{
|
||||
if (empty($objects)) {
|
||||
return [];
|
||||
}
|
||||
$values = $this->redis()->hMGet('RL_VALUE', array_values($objects));
|
||||
return array_filter(is_array($values) ? $values : [], fn(mixed $value): bool => is_string($value) && $value !== '');
|
||||
}
|
||||
|
||||
public function setRateLimit(string $object, string $value): void
|
||||
{
|
||||
$this->redis()->hSet('RL_VALUE', $object, $value);
|
||||
}
|
||||
|
||||
/** The domain is local for rspamd (legacy domain and alias domain add) */
|
||||
public function addDomainMap(string $domain): void
|
||||
{
|
||||
$this->redis()->hSet('DOMAIN_MAP', $domain, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* SOGo reads its domains at start: restart it after a domain was added (like the "restart SOGo" option of the form)
|
||||
*
|
||||
* @return ?string null on success, else the error of dockerapi
|
||||
*/
|
||||
public function restartSogo(): ?string
|
||||
{
|
||||
$result = json_decode((string)$this->dockerRestart('sogo-mailcow'), true);
|
||||
return (($result['type'] ?? '') === 'success') ? null : (string)($result['msg'] ?? 'dockerapi did not answer');
|
||||
}
|
||||
|
||||
protected function redis(): Redis
|
||||
{
|
||||
return $this->redis ?? throw new RuntimeException('Redis is not connected');
|
||||
}
|
||||
|
||||
protected function dockerRestart(string $service): mixed
|
||||
{
|
||||
return docker('post', $service, 'restart');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Legacy;
|
||||
|
||||
use Mailcow\MailboxGroup\Legacy\LegacyMailbox;
|
||||
|
||||
/**
|
||||
* The group module's bridge to the mailbox code for restores: a membership restored from a backup never applies the
|
||||
* group's mailbox template (the mailbox gets its settings from the backup).
|
||||
*/
|
||||
class NoTemplates extends LegacyMailbox
|
||||
{
|
||||
public function applyTemplate(string $username, string $name, string $template): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Model;
|
||||
|
||||
/**
|
||||
* A job of the backup runners (table `backup_job`): backup, check, prune and init run in the restic container; a
|
||||
* restore runs there (step `files`, then the merge through backup.php) and, for mails, IMAP ACLs and Sieve
|
||||
* scripts, in the dovecot container (step `mails`). Times are unix timestamps.
|
||||
*/
|
||||
class Job
|
||||
{
|
||||
public const TYPES = ['backup', 'check', 'prune', 'init', 'restore'];
|
||||
/** Types an admin starts with add/backup-job */
|
||||
public const MANUAL_TYPES = ['backup', 'check', 'prune', 'init'];
|
||||
public const ACTIVE_STATES = ['queued', 'running'];
|
||||
|
||||
public ?int $id = null;
|
||||
public string $type = 'backup';
|
||||
public string $state = 'queued';
|
||||
public ?string $step = null;
|
||||
/**
|
||||
* restore: snapshot, snapshot_time, scope, domain, mailboxes, components, mail_target, requested_role, create_domain (0|1),
|
||||
* create_mailboxes (missing when requested), acl and allowed_domains (domain admins)
|
||||
*/
|
||||
public array $params = [];
|
||||
public ?array $result = null;
|
||||
/** Only loaded for a single job */
|
||||
public ?string $log = null;
|
||||
public ?string $error = null;
|
||||
/** restore: the domain of its mailboxes, so domain admins see their restores */
|
||||
public string $domain = '';
|
||||
public string $requestedBy = '';
|
||||
public ?int $created = null;
|
||||
public ?int $started = null;
|
||||
public ?int $finished = null;
|
||||
|
||||
public function __construct(string $type = 'backup')
|
||||
{
|
||||
$this->type = $type;
|
||||
}
|
||||
|
||||
/** Row of `backup_job` with the times as unix timestamps */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$job = new self((string)$row['type']);
|
||||
$job->id = (int)$row['id'];
|
||||
$job->state = (string)$row['state'];
|
||||
$job->step = isset($row['step']) ? (string)$row['step'] : null;
|
||||
$params = is_string($row['params'] ?? null) ? json_decode($row['params'], true) : null;
|
||||
$job->params = is_array($params) ? $params : [];
|
||||
$result = is_string($row['result'] ?? null) ? json_decode($row['result'], true) : null;
|
||||
$job->result = is_array($result) ? $result : null;
|
||||
$job->log = isset($row['log']) ? (string)$row['log'] : null;
|
||||
$job->error = isset($row['error']) ? (string)$row['error'] : null;
|
||||
$job->domain = (string)($row['domain'] ?? '');
|
||||
$job->requestedBy = (string)($row['requested_by'] ?? '');
|
||||
foreach (['created', 'started', 'finished'] as $time) {
|
||||
$job->$time = isset($row[$time]) ? (int)$row[$time] : null;
|
||||
}
|
||||
return $job;
|
||||
}
|
||||
|
||||
/** Column values for a new row */
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'state' => $this->state,
|
||||
'step' => $this->step,
|
||||
'params' => json_encode($this->params ?: new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'domain' => $this->domain,
|
||||
'requested_by' => $this->requestedBy,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return string[] mailboxes of a restore */
|
||||
public function mailboxes(): array
|
||||
{
|
||||
return array_values(array_filter((array)($this->params['mailboxes'] ?? []), 'is_string'));
|
||||
}
|
||||
|
||||
/** @return string[] components of a restore */
|
||||
public function components(): array
|
||||
{
|
||||
return array_values(array_filter((array)($this->params['components'] ?? []), 'is_string'));
|
||||
}
|
||||
|
||||
/** @return string[] mailboxes of a restore that did not exist when it was requested */
|
||||
public function createMailboxes(): array
|
||||
{
|
||||
return array_values(array_filter((array)($this->params['create_mailboxes'] ?? []), 'is_string'));
|
||||
}
|
||||
|
||||
/** The restore creates its domain (requested by an admin while the domain did not exist) */
|
||||
public function createsDomain(): bool
|
||||
{
|
||||
return (int)($this->params['create_domain'] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return in_array($this->state, self::ACTIVE_STATES, true);
|
||||
}
|
||||
|
||||
/** Entry of the API; the log only with $withLog (get/backup-job/<id>) */
|
||||
public function toArray(bool $withLog = false): array
|
||||
{
|
||||
$job = [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type,
|
||||
'state' => $this->state,
|
||||
'step' => $this->step,
|
||||
'params' => $this->params ?: new \stdClass(),
|
||||
'result' => $this->result,
|
||||
'error' => $this->error,
|
||||
'domain' => $this->domain,
|
||||
'requested_by' => $this->requestedBy,
|
||||
'created' => $this->created,
|
||||
'started' => $this->started,
|
||||
'finished' => $this->finished,
|
||||
];
|
||||
if ($withLog) {
|
||||
$job['log'] = (string)$this->log;
|
||||
}
|
||||
return $job;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Model;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
|
||||
/**
|
||||
* The merge of one mailbox of a restore job: what the backup holds, who asked for the restore and what the merge
|
||||
* did per component (added, skipped, errors), plus the caches to refresh afterwards.
|
||||
*/
|
||||
class RestoreRun
|
||||
{
|
||||
/** @var array<string, array{added: int, skipped: int, errors: string[]}> */
|
||||
public array $results = [];
|
||||
/** Mailboxes whose SOGo identities (aliases, send-as addresses) changed */
|
||||
public array $sogoUsers = [];
|
||||
/** Mailboxes whose userdb or passdb values changed (Dovecot auth cache) */
|
||||
public array $authUsers = [];
|
||||
|
||||
/**
|
||||
* @param array $mailbox the mailbox row as it is now
|
||||
* @param array $params params of the restore job (requested_role, acl of a domain admin, scope, ...)
|
||||
* @param array $data the mailbox's section of the domain export in the snapshot
|
||||
* @param string[] $domains domains whose aliases and send-as addresses the restore may change, [] = all (admins)
|
||||
* @param string $requestedBy who requested the restore
|
||||
*/
|
||||
/** The restore created the mailbox (its own alias gets the forwarding of the backup) */
|
||||
public bool $created = false;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $username,
|
||||
public readonly string $domain,
|
||||
public readonly array $mailbox,
|
||||
public readonly array $params,
|
||||
public readonly array $data,
|
||||
public readonly array $domains = [],
|
||||
public readonly string $requestedBy = ''
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller the group module checks: an admin, or the domain admin who requested the restore with his ACL of then
|
||||
* and his domains now
|
||||
*/
|
||||
public function requester(): Context
|
||||
{
|
||||
if ($this->byAdmin()) {
|
||||
return new Context('admin', 'backup', [], true);
|
||||
}
|
||||
return new Context('domainadmin', $this->requestedBy, array_map(fn(mixed $flag): string => (string)(int)$flag, (array)($this->params['acl'] ?? [])));
|
||||
}
|
||||
|
||||
public function start(string $component): void
|
||||
{
|
||||
$this->results[$component] = ['added' => 0, 'skipped' => 0, 'errors' => []];
|
||||
}
|
||||
|
||||
public function added(string $component, int $count = 1): void
|
||||
{
|
||||
$this->results[$component]['added'] += $count;
|
||||
}
|
||||
|
||||
public function skipped(string $component, int $count = 1): void
|
||||
{
|
||||
$this->results[$component]['skipped'] += $count;
|
||||
}
|
||||
|
||||
public function error(string $component, string $error): void
|
||||
{
|
||||
$this->results[$component]['errors'][] = $error;
|
||||
}
|
||||
|
||||
/** The part of a component was rolled back: nothing of it counts */
|
||||
public function failed(string $component, string $error): void
|
||||
{
|
||||
$this->start($component);
|
||||
$this->error($component, $error);
|
||||
}
|
||||
|
||||
/** The restore was requested by an admin (domain admins are limited by their ACL and the job's domain) */
|
||||
public function byAdmin(): bool
|
||||
{
|
||||
return ($this->params['requested_role'] ?? '') === 'admin';
|
||||
}
|
||||
|
||||
/** Admins, or a domain admin with this ACL when he requested the restore */
|
||||
public function may(string $acl): bool
|
||||
{
|
||||
return $this->byAdmin() || (int)($this->params['acl'][$acl] ?? 0) === 1;
|
||||
}
|
||||
|
||||
public function mayChangeDomain(string $domain): bool
|
||||
{
|
||||
return $this->domains === [] || in_array(strtolower($domain), $this->domains, true);
|
||||
}
|
||||
|
||||
/** Rows of a list in the backup (sieve_filters, tfa, ...) */
|
||||
public function rows(string $key): array
|
||||
{
|
||||
return array_values(array_filter((array)($this->data[$key] ?? []), 'is_array'));
|
||||
}
|
||||
|
||||
public function sogo(string ...$usernames): void
|
||||
{
|
||||
array_push($this->sogoUsers, ...$usernames);
|
||||
$this->sogoUsers = array_values(array_unique($this->sogoUsers));
|
||||
}
|
||||
|
||||
public function auth(string ...$usernames): void
|
||||
{
|
||||
array_push($this->authUsers, ...$usernames);
|
||||
$this->authUsers = array_values(array_unique($this->authUsers));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Model;
|
||||
|
||||
/**
|
||||
* Backup settings (table `backup_setting`, name/value pairs) over their defaults. Secrets are stored sealed for the
|
||||
* runner (`sealed:v1:<base64>`) and never leave toArray(): the API gets `<key>_set` instead. The state_* values, the
|
||||
* SSH public key and the SFTP host key are written by the runner in the restic container and cannot be set through
|
||||
* the API (PHP writes state_target_changed when the target changes).
|
||||
*/
|
||||
class Settings
|
||||
{
|
||||
public const DEFAULTS = [
|
||||
'enabled' => '0',
|
||||
'target' => 's3',
|
||||
's3_endpoint' => 'https://s3.amazonaws.com',
|
||||
's3_bucket' => '',
|
||||
's3_path' => '',
|
||||
's3_region' => '',
|
||||
's3_access_key' => '',
|
||||
's3_secret_key' => '',
|
||||
's3_path_style' => '0',
|
||||
'sftp_host' => '',
|
||||
'sftp_port' => '22',
|
||||
'sftp_user' => '',
|
||||
'sftp_path' => '',
|
||||
'sftp_public_key' => '',
|
||||
'sftp_host_key' => '',
|
||||
'rest_url' => '',
|
||||
'rest_user' => '',
|
||||
'rest_password' => '',
|
||||
'local_path' => 'mailcow',
|
||||
'password' => '',
|
||||
'password_confirmed' => '0',
|
||||
// JSON list of the scheduled backups: {"days": [weekdays, 0 = Sunday], "time": "HH:MM"}
|
||||
'schedule' => '[{"days":[0,1,2,3,4,5,6],"time":"01:00"}]',
|
||||
'keep_hourly' => '0',
|
||||
'keep_daily' => '7',
|
||||
'keep_weekly' => '4',
|
||||
'keep_monthly' => '12',
|
||||
'keep_yearly' => '0',
|
||||
'prune_weekday' => '0',
|
||||
'max_unused' => '10%',
|
||||
'check_subset' => '30',
|
||||
'include_index' => '1',
|
||||
'include_fts' => '1',
|
||||
'limit_upload' => '0',
|
||||
'notify_email' => '',
|
||||
'state_initialized' => '0',
|
||||
'state_runner_seen' => '',
|
||||
'state_last_backup' => '',
|
||||
'state_last_success' => '',
|
||||
'state_repo_size' => '',
|
||||
'state_repo_stats' => '',
|
||||
'state_schedule_since' => '',
|
||||
'state_schedule' => '',
|
||||
// public key (base64, X25519) the secrets are sealed with; the private key stays in the restic container
|
||||
'state_secrets_pubkey' => '',
|
||||
'state_secrets_error' => '',
|
||||
// JSON {old_target, old_addresses, changed, changed_by, time}: the runner mails the old addresses and clears it
|
||||
'state_target_changed' => '',
|
||||
// RESTIC_BACKUP_MODE of mailcow.conf as the runner sees it
|
||||
'state_backup_mode' => '',
|
||||
];
|
||||
public const SECRETS = ['password', 's3_secret_key', 'rest_password'];
|
||||
/** Written by the runner only */
|
||||
public const RUNNER = ['sftp_public_key', 'sftp_host_key', 'state_initialized', 'state_runner_seen', 'state_last_backup',
|
||||
'state_last_success', 'state_repo_size', 'state_repo_stats', 'state_schedule_since', 'state_schedule', 'state_secrets_pubkey',
|
||||
'state_secrets_error', 'state_target_changed', 'state_backup_mode'];
|
||||
/** Prefix of a sealed secret (sodium_crypto_box_seal with state_secrets_pubkey) */
|
||||
public const SEALED = 'sealed:v1:';
|
||||
public const TARGETS = ['s3', 'sftp', 'rest', 'local'];
|
||||
/** The connection of each target: a change (or another target or repository password) resets state_initialized */
|
||||
public const CONNECTION = [
|
||||
's3' => ['s3_endpoint', 's3_bucket', 's3_path', 's3_region', 's3_access_key', 's3_secret_key', 's3_path_style'],
|
||||
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
|
||||
'rest' => ['rest_url', 'rest_user', 'rest_password'],
|
||||
'local' => ['local_path'],
|
||||
];
|
||||
/** Values a target cannot work without (besides the repository password) */
|
||||
public const REQUIRED = [
|
||||
's3' => ['s3_endpoint', 's3_bucket', 's3_access_key', 's3_secret_key'],
|
||||
'sftp' => ['sftp_host', 'sftp_port', 'sftp_user', 'sftp_path'],
|
||||
'rest' => ['rest_url'],
|
||||
'local' => ['local_path'],
|
||||
];
|
||||
/** Returned as numbers by the API ('' = not known yet: null; prune_weekday '' = never stays '') */
|
||||
private const NUMBERS = ['enabled', 's3_path_style', 'sftp_port', 'password_confirmed', 'keep_hourly', 'keep_daily', 'keep_weekly', 'keep_monthly',
|
||||
'keep_yearly', 'prune_weekday', 'check_subset', 'include_index', 'include_fts', 'limit_upload', 'state_initialized',
|
||||
'state_runner_seen', 'state_last_backup', 'state_last_success', 'state_repo_size', 'state_schedule_since'];
|
||||
private const JSON = ['schedule', 'state_repo_stats', 'state_target_changed'];
|
||||
|
||||
/** @param array<string, string> $values every key of DEFAULTS */
|
||||
public function __construct(public array $values = self::DEFAULTS)
|
||||
{
|
||||
}
|
||||
|
||||
/** Stored values (name => value) over the defaults; unknown names are dropped */
|
||||
public static function fromStored(array $stored): self
|
||||
{
|
||||
$values = self::DEFAULTS;
|
||||
foreach ($stored as $name => $value) {
|
||||
if (array_key_exists($name, $values)) {
|
||||
$values[$name] = (string)$value;
|
||||
}
|
||||
}
|
||||
return new self($values);
|
||||
}
|
||||
|
||||
public function get(string $name): string
|
||||
{
|
||||
return $this->values[$name] ?? '';
|
||||
}
|
||||
|
||||
/** The target has its required values and the repository password is set */
|
||||
public function configured(): bool
|
||||
{
|
||||
$required = self::REQUIRED[$this->get('target')] ?? null;
|
||||
if ($required === null || $this->get('password') === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ($required as $name) {
|
||||
if ($this->get($name) === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule as [weekday (0 = Sunday), hour, minute] slots, sorted and unique
|
||||
*
|
||||
* @return array<int, array{int, int, int}>
|
||||
*/
|
||||
public function scheduleSlots(): array
|
||||
{
|
||||
$entries = json_decode($this->get('schedule'), true);
|
||||
$slots = [];
|
||||
foreach (is_array($entries) ? $entries : [] as $entry) {
|
||||
if (!is_array($entry) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', (string)($entry['time'] ?? ''), $time) || !is_array($entry['days'] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entry['days'] as $day) {
|
||||
if (is_int($day) && $day >= 0 && $day <= 6) {
|
||||
$slots[] = $day * 1440 + (int)$time[1] * 60 + (int)$time[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
$slots = array_unique($slots);
|
||||
sort($slots);
|
||||
return array_map(fn(int $slot): array => [intdiv($slot, 1440), intdiv($slot % 1440, 60), $slot % 60], $slots);
|
||||
}
|
||||
|
||||
/** The longest time between two scheduled backups of a week in seconds, null without a schedule */
|
||||
public function scheduleGap(): ?int
|
||||
{
|
||||
$slots = array_map(fn(array $slot): int => $slot[0] * 86400 + $slot[1] * 3600 + $slot[2] * 60, $this->scheduleSlots());
|
||||
if (empty($slots)) {
|
||||
return null;
|
||||
}
|
||||
$gaps = [$slots[0] + 7 * 86400 - end($slots)];
|
||||
for ($i = 1; $i < count($slots); $i++) {
|
||||
$gaps[] = $slots[$i] - $slots[$i - 1];
|
||||
}
|
||||
return max($gaps);
|
||||
}
|
||||
|
||||
/** Entry of get/backup-settings: every key, secrets as <key>_set, plus `configured` */
|
||||
public function toArray(): array
|
||||
{
|
||||
$settings = [];
|
||||
foreach ($this->values as $name => $value) {
|
||||
if (in_array($name, self::SECRETS, true)) {
|
||||
$settings[$name . '_set'] = (int)($value !== '');
|
||||
} elseif (in_array($name, self::JSON, true)) {
|
||||
$decoded = json_decode($value, true);
|
||||
$settings[$name] = is_array($decoded) ? $decoded : null;
|
||||
} elseif (in_array($name, self::NUMBERS, true)) {
|
||||
$settings[$name] = ($value === '') ? (($name === 'prune_weekday') ? '' : null) : (int)$value;
|
||||
} else {
|
||||
$settings[$name] = $value;
|
||||
}
|
||||
}
|
||||
$settings['configured'] = (int)$this->configured();
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Model;
|
||||
|
||||
/**
|
||||
* A snapshot of the repository (table `backup_snapshot`, the runner's cache of `restic snapshots --json`).
|
||||
*/
|
||||
class Snapshot
|
||||
{
|
||||
public string $id = '';
|
||||
public string $shortId = '';
|
||||
/** unix timestamp */
|
||||
public int $time = 0;
|
||||
public string $hostname = '';
|
||||
/** @var string[] */
|
||||
public array $tags = [];
|
||||
/** restic's summary of the backup (data_added, files_new, ...) */
|
||||
public ?array $summary = null;
|
||||
/** @var ?string[] domains of its manifest, null for snapshots not made by mailcow */
|
||||
public ?array $domains = null;
|
||||
/** @var array<string, ?int> domain => its creation time (unix) when the snapshot was made, from the snapshot row */
|
||||
public array $created = [];
|
||||
/**
|
||||
* @var ?array<string, array{created: ?int, mailboxes: array<string, string>}> per domain its creation time (unix, null
|
||||
* when not recorded) and username => name of its mailboxes; null = not known (snapshots this installation did
|
||||
* not make itself, e.g. of another server in the same repository)
|
||||
*/
|
||||
public ?array $content = null;
|
||||
|
||||
/** Row of `backup_snapshot` with the time as unix timestamp */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$snapshot = new self();
|
||||
$snapshot->id = (string)$row['id'];
|
||||
$snapshot->shortId = (string)$row['short_id'];
|
||||
$snapshot->time = (int)$row['time'];
|
||||
$snapshot->hostname = (string)($row['hostname'] ?? '');
|
||||
$tags = (string)($row['tags'] ?? '');
|
||||
$decoded = json_decode($tags, true);
|
||||
$tags = is_array($decoded) ? $decoded : explode(',', $tags);
|
||||
$snapshot->tags = array_values(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', $tags), 'strlen'));
|
||||
$summary = is_string($row['summary'] ?? null) ? json_decode($row['summary'], true) : null;
|
||||
$snapshot->summary = is_array($summary) ? $summary : null;
|
||||
// {domain: creation time}: lists of snapshots need no content
|
||||
$domains = is_string($row['domains'] ?? null) ? json_decode($row['domains'], true) : null;
|
||||
if (is_array($domains)) {
|
||||
$snapshot->domains = [];
|
||||
foreach ($domains as $domain => $created) {
|
||||
$snapshot->domains[] = (string)$domain;
|
||||
$snapshot->created[strtolower((string)$domain)] = is_numeric($created) ? (int)$created : null;
|
||||
}
|
||||
}
|
||||
if (is_string($row['content'] ?? null) && $row['content'] !== '') {
|
||||
$snapshot->content = self::decodeContent($row['content']);
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* `content` (gzip JSON) written by the runner: {"domains": {"<domain>": {"created": <unix>, "mailboxes": {"<username>": "<name>"}}}}.
|
||||
* Keys lower case; null when broken.
|
||||
*/
|
||||
public static function decodeContent(string $gzip): ?array
|
||||
{
|
||||
$json = @gzdecode($gzip);
|
||||
$data = is_string($json) ? json_decode($json, true) : null;
|
||||
if (!is_array($data) || !is_array($data['domains'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
$content = [];
|
||||
foreach ($data['domains'] as $domain => $entry) {
|
||||
$mailboxes = is_array($entry) ? ($entry['mailboxes'] ?? []) : [];
|
||||
$created = is_numeric($entry['created'] ?? null) ? (int)$entry['created'] : null;
|
||||
$names = [];
|
||||
foreach (is_array($mailboxes) ? $mailboxes : [] as $username => $name) {
|
||||
$names[strtolower((string)$username)] = is_scalar($name) ? (string)$name : '';
|
||||
}
|
||||
$content[strtolower((string)$domain)] = ['created' => $created, 'mailboxes' => $names];
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/** Domains of the snapshot: those of its content and of its manifest (an export may have failed) */
|
||||
public function domainList(): array
|
||||
{
|
||||
return array_values(array_unique(array_merge(array_map('strval', array_keys($this->content ?? [])), array_map('strtolower', $this->domains ?? []))));
|
||||
}
|
||||
|
||||
/** @return ?array<string, string> username => name of the domain's mailboxes, null when the content does not list the domain */
|
||||
public function mailboxesOf(string $domain): ?array
|
||||
{
|
||||
return $this->content[strtolower($domain)]['mailboxes'] ?? null;
|
||||
}
|
||||
|
||||
/** Creation time of the domain when the snapshot was made, null when not recorded */
|
||||
public function createdOf(string $domain): ?int
|
||||
{
|
||||
return $this->content[strtolower($domain)]['created'] ?? $this->created[strtolower($domain)] ?? null;
|
||||
}
|
||||
|
||||
public function hasDomain(string $domain): bool
|
||||
{
|
||||
return in_array(strtolower($domain), $this->domainList(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* RESTIC_BACKUP_MODE of the backup (tag mode:<mode>, none for all): all, data (without the mailcow directory) or
|
||||
* config (without mails)
|
||||
*/
|
||||
public function mode(): string
|
||||
{
|
||||
foreach ($this->tags as $tag) {
|
||||
if (str_starts_with($tag, 'mode:')) {
|
||||
return substr($tag, 5);
|
||||
}
|
||||
}
|
||||
return 'all';
|
||||
}
|
||||
|
||||
/** Snapshots not made by mailcow know neither domains nor mailboxes */
|
||||
public function knowsDomains(): bool
|
||||
{
|
||||
return $this->content !== null || $this->domains !== null;
|
||||
}
|
||||
|
||||
/** Entry of the API for admins */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'short_id' => $this->shortId,
|
||||
'time' => $this->time,
|
||||
'hostname' => $this->hostname,
|
||||
'tags' => $this->tags,
|
||||
'summary' => $this->summary,
|
||||
'domains' => $this->domains,
|
||||
'mode' => $this->mode(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Reads what the export of a domain holds: the domain with its settings, its aliases and a page of its mailboxes with
|
||||
* every row that belongs to a mailbox. Rows are read with all their columns (SELECT *).
|
||||
*/
|
||||
class ExportRepository extends Repository
|
||||
{
|
||||
/** @return string[] all domains */
|
||||
public function domains(): array
|
||||
{
|
||||
return $this->query("SELECT `domain` FROM `domain` ORDER BY `domain`")->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
public function dbVersion(): string
|
||||
{
|
||||
return (string)$this->query("SELECT `version` FROM `versions` WHERE `application` = 'db_schema'")->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return array[] rows of a table matching column => value (table and column names from code) */
|
||||
public function rows(string $table, string $column, string $value): array
|
||||
{
|
||||
self::identifier($table);
|
||||
self::identifier($column);
|
||||
return $this->query("SELECT * FROM `$table` WHERE `$column` = :value", ['value' => $value])->fetchAll();
|
||||
}
|
||||
|
||||
/** @return string[] usernames of the domain's mailboxes */
|
||||
public function usernames(string $domain): array
|
||||
{
|
||||
return $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain", ['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/** @return array[] the domain's mailbox rows after $after in username order (keyset paging), at most $limit */
|
||||
public function mailboxesAfter(string $domain, string $after, int $limit): array
|
||||
{
|
||||
return $this->query("SELECT * FROM `mailbox` WHERE `domain` = :domain AND `username` > :after ORDER BY `username` LIMIT " . max(1, $limit),
|
||||
['domain' => $domain, 'after' => $after])->fetchAll();
|
||||
}
|
||||
|
||||
/** @return array[] alias rows of the domain and its alias domains */
|
||||
public function aliases(string $domain): array
|
||||
{
|
||||
return $this->query("SELECT * FROM `alias` WHERE `domain` = :domain
|
||||
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target) ORDER BY `id`",
|
||||
['domain' => $domain, 'target' => $domain])->fetchAll();
|
||||
}
|
||||
|
||||
/** @return array[] alias rows of any domain whose goto may name a mailbox of the domain (the caller checks) */
|
||||
public function aliasesPointingTo(string $domain): array
|
||||
{
|
||||
return $this->query("SELECT * FROM `alias` WHERE `goto` LIKE :pattern ORDER BY `id`",
|
||||
['pattern' => '%@' . addcslashes($domain, '%_\\') . '%'])->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $table table with rows of mailboxes
|
||||
* @param string $column its column holding the username
|
||||
* @param string[] $usernames
|
||||
* @return array[] rows of these mailboxes
|
||||
*/
|
||||
public function rowsOfMailboxes(string $table, string $column, array $usernames): array
|
||||
{
|
||||
self::identifier($table);
|
||||
self::identifier($column);
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in($usernames);
|
||||
return $this->query("SELECT * FROM `$table` WHERE `$column` IN ($in)", $params)->fetchAll();
|
||||
}
|
||||
|
||||
/** @return array[] username, id, name, domain, source of the group memberships of these mailboxes */
|
||||
public function groupMemberships(array $usernames): array
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in($usernames);
|
||||
return $this->query("SELECT `gm`.`username`, `g`.`id`, `g`.`name`, `g`.`domain`, `gm`.`source` FROM `mailbox_group_member` `gm`
|
||||
JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` WHERE `gm`.`username` IN ($in) ORDER BY `g`.`id`", $params)->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $table mail_policy or retention_rule
|
||||
* @param string $member its member table
|
||||
* @param string $key the member table's column with the id
|
||||
* @return array[] username, id, name, domain of the direct assignments of these mailboxes
|
||||
*/
|
||||
public function assignments(string $table, string $member, string $key, array $usernames): array
|
||||
{
|
||||
self::identifier($table);
|
||||
self::identifier($member);
|
||||
self::identifier($key);
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in($usernames);
|
||||
return $this->query("SELECT `a`.`username`, `p`.`id`, `p`.`name`, `p`.`domain` FROM `$member` `a`
|
||||
JOIN `$table` `p` ON `p`.`id` = `a`.`$key` WHERE `a`.`username` IN ($in) ORDER BY `p`.`id`", $params)->fetchAll();
|
||||
}
|
||||
|
||||
/** @return array[] sync jobs of these mailboxes (without their log) with host1, port1 and name of the source */
|
||||
public function syncJobs(array $usernames): array
|
||||
{
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
[$in, $params] = self::in($usernames);
|
||||
$rows = $this->query("SELECT `i`.*, `s`.`host1` AS `source_host1`, `s`.`port1` AS `source_port1`, `s`.`name` AS `source_name`
|
||||
FROM `imapsync` `i` LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` IN ($in) ORDER BY `i`.`id`", $params)->fetchAll();
|
||||
foreach ($rows as &$row) {
|
||||
unset($row['returned_text']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array[] BCC maps of the whole domain (local_dest "@domain", older rows "domain") */
|
||||
public function domainBccMaps(string $domain): array
|
||||
{
|
||||
return $this->query("SELECT * FROM `bcc_maps` WHERE `local_dest` IN (:domain, :at)", ['domain' => $domain, 'at' => '@' . $domain])->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $table mail_policy or retention_rule
|
||||
* @param string $member its member table
|
||||
* @param string $key the member table's column with the id
|
||||
* @return array[] rows of the domain, each with `group_members`: the names of the groups it is assigned to
|
||||
*/
|
||||
public function domainRules(string $table, string $member, string $key, string $domain): array
|
||||
{
|
||||
self::identifier($table);
|
||||
self::identifier($member);
|
||||
self::identifier($key);
|
||||
$rows = $this->query("SELECT * FROM `$table` WHERE `domain` = :domain ORDER BY `id`", ['domain' => $domain])->fetchAll();
|
||||
foreach ($rows as &$row) {
|
||||
$row['group_members'] = $this->query("SELECT `g`.`name` FROM `$member` `a` JOIN `mailbox_group` `g` ON `g`.`id` = `a`.`group_id`
|
||||
WHERE `a`.`$key` = :id ORDER BY `g`.`name`", ['id' => $row['id']])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: array} placeholders and parameters of an IN list */
|
||||
private static function in(array $values): array
|
||||
{
|
||||
$names = [];
|
||||
$params = [];
|
||||
foreach (array_values($values) as $i => $value) {
|
||||
$names[] = ':in' . $i;
|
||||
$params['in' . $i] = $value;
|
||||
}
|
||||
return [implode(', ', $names), $params];
|
||||
}
|
||||
|
||||
/** Table and column names come from code only; this keeps it that way */
|
||||
private static function identifier(string $name): void
|
||||
{
|
||||
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
|
||||
throw new \InvalidArgumentException('invalid identifier');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Repository;
|
||||
|
||||
use Mailcow\Backup\Model\Job;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Table `backup_job` (the runners claim, log and finish the jobs) and the lookups a new restore job needs.
|
||||
*/
|
||||
class JobRepository extends Repository
|
||||
{
|
||||
private const COLUMNS = "`id`, `type`, `state`, `step`, `params`, `result`, `error`, `domain`, `requested_by`,
|
||||
UNIX_TIMESTAMP(`created`) AS `created`, UNIX_TIMESTAMP(`started`) AS `started`, UNIX_TIMESTAMP(`finished`) AS `finished`";
|
||||
/** Serializes the check for active jobs and the insert of a new one */
|
||||
private const LOCK = 'mailcow_backup_job';
|
||||
|
||||
public function find(int $id, bool $withLog = false): ?Job
|
||||
{
|
||||
$row = $this->query("SELECT " . self::COLUMNS . ($withLog ? ", `log`" : "") . " FROM `backup_job` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||
return is_array($row) ? Job::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $type only jobs of this type
|
||||
* @return Job[] newest first: all jobs for admins, the restores of their domains (since the domain was created) for
|
||||
* domain admins
|
||||
*/
|
||||
public function visible(Context $context, int $limit, ?string $type = null): array
|
||||
{
|
||||
if ($context->hasAdminRights()) {
|
||||
[$where, $params] = ['1=1', []];
|
||||
} elseif ($context->isDomainAdmin()) {
|
||||
// restores of their domains, requested since the domain was created (not those of a former domain of that name)
|
||||
[$where, $params] = ["`type` = 'restore' AND `domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :da)
|
||||
AND `created` >= (SELECT `d`.`created` FROM `domain` `d` WHERE `d`.`domain` = `backup_job`.`domain`)", ['da' => $context->username]];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
if ($type !== null) {
|
||||
$where .= " AND `type` = :type";
|
||||
$params['type'] = $type;
|
||||
}
|
||||
return array_map(fn(array $row): Job => Job::fromRow($row),
|
||||
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE $where ORDER BY `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll());
|
||||
}
|
||||
|
||||
/** @return Job[] queued and running jobs */
|
||||
public function active(): array
|
||||
{
|
||||
return array_map(fn(array $row): Job => Job::fromRow($row),
|
||||
$this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `state` IN ('queued', 'running') ORDER BY `id`")->fetchAll());
|
||||
}
|
||||
|
||||
/** @return array<string, Job> type => its newest job */
|
||||
public function latestOfEachType(): array
|
||||
{
|
||||
$jobs = [];
|
||||
foreach ($this->query("SELECT " . self::COLUMNS . " FROM `backup_job` WHERE `id` IN (SELECT MAX(`id`) FROM `backup_job` GROUP BY `type`)")->fetchAll() as $row) {
|
||||
$jobs[(string)$row['type']] = Job::fromRow($row);
|
||||
}
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/** A backup job was created by the schedule since this unix time */
|
||||
public function scheduledSince(int $since): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `backup_job` WHERE `type` = 'backup' AND `requested_by` = 'schedule' AND `created` >= FROM_UNIXTIME(:since) LIMIT 1",
|
||||
['since' => $since])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the job after $check(active jobs) passed; $check throws a UserError on a conflict. A named lock keeps two
|
||||
* requests from queueing the same work at once.
|
||||
*
|
||||
* @param callable(Job[]): void $check
|
||||
* @throws UserError
|
||||
*/
|
||||
public function insertExclusive(Job $job, callable $check): int
|
||||
{
|
||||
if ((int)$this->query("SELECT GET_LOCK('" . self::LOCK . "', 10)")->fetchColumn() !== 1) {
|
||||
throw new UserError('backup_job_active', [$job->type]);
|
||||
}
|
||||
try {
|
||||
$check($this->active());
|
||||
$job->id = $this->insertRow('backup_job', $job->toRow());
|
||||
return $job->id;
|
||||
} finally {
|
||||
$this->query("SELECT RELEASE_LOCK('" . self::LOCK . "')");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a queued or running job; the restic runner stops at its next check. A restore in the dovecot step
|
||||
* (mails, mails_running) cannot be cancelled. False when the job finished or reached that step meanwhile.
|
||||
*/
|
||||
public function cancel(int $id): bool
|
||||
{
|
||||
return $this->query("UPDATE `backup_job` SET `state` = 'cancelled', `finished` = NOW() WHERE `id` = :id
|
||||
AND `state` IN ('queued', 'running') AND NOT (`type` = 'restore' AND COALESCE(`step`, '') IN ('mails', 'mails_running'))",
|
||||
['id' => $id])->rowCount() > 0;
|
||||
}
|
||||
|
||||
public function isDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Domain of a mailbox, null if there is none */
|
||||
public function mailboxDomain(string $username): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username", ['username' => $username])->fetchColumn();
|
||||
return is_string($domain) ? strtolower($domain) : null;
|
||||
}
|
||||
|
||||
/** Unix time the domain was created, null if it does not exist */
|
||||
public function domainCreated(string $domain): ?int
|
||||
{
|
||||
$created = $this->query("SELECT UNIX_TIMESTAMP(`created`) FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
return ($created === false || $created === null) ? null : (int)$created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $domains
|
||||
* @return array{0: array<string, true>, 1: array<string, array<string, true>>} the domains that exist, and per domain
|
||||
* the usernames of its mailboxes; one query each per 500 domains
|
||||
*/
|
||||
public function domainsAndUsernames(array $domains): array
|
||||
{
|
||||
$existing = [];
|
||||
$usernames = [];
|
||||
foreach (array_chunk(array_values(array_unique($domains)), 500) as $chunk) {
|
||||
$params = [];
|
||||
foreach ($chunk as $i => $domain) {
|
||||
$params['d' . $i] = $domain;
|
||||
}
|
||||
$in = implode(', ', array_map(fn(string $name): string => ':' . $name, array_keys($params)));
|
||||
foreach ($this->query("SELECT `domain` FROM `domain` WHERE `domain` IN ($in)", $params)->fetchAll(PDO::FETCH_COLUMN) as $domain) {
|
||||
$existing[strtolower((string)$domain)] = true;
|
||||
}
|
||||
foreach ($this->query("SELECT `domain`, `username` FROM `mailbox` WHERE `domain` IN ($in)", $params)->fetchAll() as $row) {
|
||||
$usernames[strtolower((string)$row['domain'])][strtolower((string)$row['username'])] = true;
|
||||
}
|
||||
}
|
||||
return [$existing, $usernames];
|
||||
}
|
||||
|
||||
/** @return array<string, string> username => name of the mailboxes of a domain */
|
||||
public function mailboxNames(string $domain): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($this->query("SELECT `username`, `name` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`", ['domain' => $domain])->fetchAll() as $row) {
|
||||
$names[strtolower((string)$row['username'])] = (string)$row['name'];
|
||||
}
|
||||
return $names;
|
||||
}
|
||||
|
||||
/** @return string[] all mailboxes of a domain */
|
||||
public function mailboxesOf(string $domain): array
|
||||
{
|
||||
return array_map('strtolower', $this->query("SELECT `username` FROM `mailbox` WHERE `domain` = :domain ORDER BY `username`",
|
||||
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** @return string[] alias domains of a domain */
|
||||
public function aliasDomainsOf(string $domain): array
|
||||
{
|
||||
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
|
||||
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Reads and writes of the restore merge. Rows of a backup may come from an older schema, so inserts and updates only
|
||||
* use the columns the table has now. Table and column names come from code, never from the backup.
|
||||
*/
|
||||
class RestoreRepository extends Repository
|
||||
{
|
||||
/** @var array<string, string[]> table => columns */
|
||||
private array $columns = [];
|
||||
|
||||
/** Runs $work in one transaction */
|
||||
public function inTransaction(callable $work): mixed
|
||||
{
|
||||
return $this->transaction($work);
|
||||
}
|
||||
|
||||
/** Runs $work in a savepoint of the running transaction: an error undoes only its writes and is thrown on */
|
||||
public function savepoint(string $name, callable $work): mixed
|
||||
{
|
||||
self::identifier($name);
|
||||
$this->pdo->exec("SAVEPOINT `$name`");
|
||||
try {
|
||||
$result = $work();
|
||||
$this->pdo->exec("RELEASE SAVEPOINT `$name`");
|
||||
return $result;
|
||||
} catch (Throwable $e) {
|
||||
$this->pdo->exec("ROLLBACK TO SAVEPOINT `$name`");
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs $work while holding a named lock, so a retried request of the runner waits for the first one instead of
|
||||
* merging the same mailbox twice; null when the lock was not free within $timeout seconds
|
||||
*/
|
||||
public function locked(string $key, callable $work, int $timeout = 120): mixed
|
||||
{
|
||||
$name = 'mailcow_backup_' . md5($key);
|
||||
if ((int)$this->query("SELECT GET_LOCK(:name, " . max(0, $timeout) . ")", ['name' => $name])->fetchColumn() !== 1) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return $work();
|
||||
} finally {
|
||||
$this->query("SELECT RELEASE_LOCK(:name)", ['name' => $name]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Quota rows of a new mailbox (like the legacy mailbox add) */
|
||||
public function resetQuota(string $username): void
|
||||
{
|
||||
foreach (['quota2', 'quota2replica'] as $table) {
|
||||
$this->query("INSERT INTO `$table` (`username`, `bytes`, `messages`) VALUES (:username, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE `bytes` = 0, `messages` = 0", ['username' => $username]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular mailboxes of a domain (the domain's mailbox limit counts these) */
|
||||
public function mailboxCount(string $domain): int
|
||||
{
|
||||
return (int)$this->query("SELECT COUNT(*) FROM `mailbox` WHERE `domain` = :domain AND `kind` = ''", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** External send-as rights for a domain that is local now (legacy domain and alias domain add) */
|
||||
public function dropExternalSenderAcl(string $domain): void
|
||||
{
|
||||
$this->query("DELETE FROM `sender_acl` WHERE `external` = 1 AND `send_as` LIKE :domain", ['domain' => '%@' . addcslashes($domain, '%_\\')]);
|
||||
}
|
||||
|
||||
/** @return string[] the columns of a table in the current schema */
|
||||
public function columns(string $table): array
|
||||
{
|
||||
self::identifier($table);
|
||||
return $this->columns[$table] ??= $this->query("SELECT `COLUMN_NAME` FROM `information_schema`.`COLUMNS`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = :table ORDER BY `ORDINAL_POSITION`", ['table' => $table])->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
/** @return array[] rows of a table matching every condition (column => value) */
|
||||
public function rows(string $table, array $where): array
|
||||
{
|
||||
[$condition, $params] = $this->where($table, $where);
|
||||
return $this->query("SELECT * FROM `$table` WHERE $condition", $params)->fetchAll();
|
||||
}
|
||||
|
||||
/** Inserts a row with the values of the columns the table has now; returns the new id (0 without one) */
|
||||
public function insert(string $table, array $values): int
|
||||
{
|
||||
return $this->insertRow($table, $this->existing($table, $values));
|
||||
}
|
||||
|
||||
/** Updates the rows matching $where with the values of the columns the table has now */
|
||||
public function update(string $table, array $where, array $values): int
|
||||
{
|
||||
$values = $this->existing($table, $values);
|
||||
if (empty($values)) {
|
||||
return 0;
|
||||
}
|
||||
[$condition, $params] = $this->where($table, $where);
|
||||
$set = [];
|
||||
foreach (array_keys($values) as $i => $column) {
|
||||
$set[] = "`$column` = :v$i";
|
||||
$params['v' . $i] = $values[$column];
|
||||
}
|
||||
return $this->query("UPDATE `$table` SET " . implode(', ', $set) . " WHERE $condition", $params)->rowCount();
|
||||
}
|
||||
|
||||
/** A primary or alias domain */
|
||||
public function domainExists(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias",
|
||||
['domain' => $domain, 'alias' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** The primary domain of a domain or alias domain, null if mailcow has neither */
|
||||
public function primaryDomain(string $domain): ?string
|
||||
{
|
||||
$primary = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain
|
||||
UNION SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :alias", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
|
||||
return is_string($primary) ? strtolower($primary) : null;
|
||||
}
|
||||
|
||||
/** An active primary or alias domain (like the legacy is_local_mailcow_domain()) */
|
||||
public function isLocalDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain AND `active` = 1
|
||||
UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :alias AND `active` = 1", ['domain' => $domain, 'alias' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return string[] alias domains of a domain */
|
||||
public function aliasDomainsOf(string $domain): array
|
||||
{
|
||||
return array_map('strtolower', $this->query("SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :domain",
|
||||
['domain' => $domain])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/**
|
||||
* What an address is now: 'mailbox' (also local@alias-domain of a mailbox), 'resource' (a mailbox of kind
|
||||
* location, thing or group), 'alias', 'spamalias' or null
|
||||
*/
|
||||
public function addressKind(string $address): ?string
|
||||
{
|
||||
$kind = $this->query("SELECT `kind` FROM `mailbox` WHERE `username` = :address", ['address' => $address])->fetchColumn();
|
||||
if (is_string($kind)) {
|
||||
return preg_match('/location|thing|group/', $kind) ? 'resource' : 'mailbox';
|
||||
}
|
||||
[$local, $domain] = array_pad(explode('@', $address, 2), 2, '');
|
||||
if ((bool)$this->query("SELECT 1 FROM `mailbox` `m` JOIN `alias_domain` `ad` ON `ad`.`alias_domain` = :domain
|
||||
WHERE `m`.`username` = CONCAT(:local, '@', `ad`.`target_domain`)", ['domain' => $domain, 'local' => $local])->fetchColumn()) {
|
||||
return 'mailbox';
|
||||
}
|
||||
if ((bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
|
||||
return 'alias';
|
||||
}
|
||||
if ((bool)$this->query("SELECT 1 FROM `spamalias` WHERE `address` = :address", ['address' => $address])->fetchColumn()) {
|
||||
return 'spamalias';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Aliases the domain may still get (domain limit minus its aliases and those of its alias domains, like the legacy code) */
|
||||
public function aliasesLeft(string $domain): int
|
||||
{
|
||||
$limit = $this->query("SELECT `aliases` FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
if ($limit === false) {
|
||||
return 0;
|
||||
}
|
||||
$used = (int)$this->query("SELECT COUNT(*) FROM `alias` WHERE (`domain` = :domain
|
||||
OR `domain` IN (SELECT `alias_domain` FROM `alias_domain` WHERE `target_domain` = :target))
|
||||
AND `address` NOT IN (SELECT `username` FROM `mailbox`)", ['domain' => $domain, 'target' => $domain])->fetchColumn();
|
||||
return (int)$limit - $used;
|
||||
}
|
||||
|
||||
/** Quota (bytes) the other mailboxes of the domain have */
|
||||
public function quotaInUse(string $domain, string $exceptUsername): int
|
||||
{
|
||||
return (int)$this->query("SELECT COALESCE(SUM(`quota`), 0) FROM `mailbox` WHERE `domain` = :domain AND `kind` = '' AND `username` != :username",
|
||||
['domain' => $domain, 'username' => $exceptUsername])->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return array[] user1, host1 and port1 of the mailbox's sync jobs */
|
||||
public function syncJobsOf(string $username): array
|
||||
{
|
||||
return $this->query("SELECT `i`.`user1`, `s`.`host1`, `s`.`port1` FROM `imapsync` `i`
|
||||
LEFT JOIN `imapsync_source` `s` ON `s`.`id` = `i`.`source_id` WHERE `i`.`user2` = :username", ['username' => $username])->fetchAll();
|
||||
}
|
||||
|
||||
/** @return int[] ids of the sync sources with this host and port the mailbox may use, oldest first */
|
||||
public function syncSourcesFor(string $host, int $port, string $username, string $domain): array
|
||||
{
|
||||
return array_map('intval', $this->query("SELECT `s`.`id` FROM `imapsync_source` `s` WHERE `s`.`host1` = :host AND `s`.`port1` = :port
|
||||
AND (`s`.`scope` = 'all'
|
||||
OR (`s`.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` `d` WHERE `d`.`source_id` = `s`.`id` AND `d`.`domain` = :domain))
|
||||
OR (`s`.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` `u` WHERE `u`.`source_id` = `s`.`id` AND `u`.`username` = :username)))
|
||||
ORDER BY `s`.`id`", ['host' => $host, 'port' => $port, 'domain' => $domain, 'username' => $username])->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: array} condition and parameters of column => value pairs */
|
||||
private function where(string $table, array $where): array
|
||||
{
|
||||
self::identifier($table);
|
||||
$parts = [];
|
||||
$params = [];
|
||||
foreach (array_keys($where) as $i => $column) {
|
||||
self::identifier((string)$column);
|
||||
if ($where[$column] === null) {
|
||||
$parts[] = "`$column` IS NULL";
|
||||
continue;
|
||||
}
|
||||
$parts[] = "`$column` = :w$i";
|
||||
$params['w' . $i] = $where[$column];
|
||||
}
|
||||
return [empty($parts) ? '1=1' : implode(' AND ', $parts), $params];
|
||||
}
|
||||
|
||||
/** The values of the columns the table has now */
|
||||
private function existing(string $table, array $values): array
|
||||
{
|
||||
return array_intersect_key($values, array_flip($this->columns($table)));
|
||||
}
|
||||
|
||||
/** Table, column and savepoint names come from code only; this keeps it that way */
|
||||
private static function identifier(string $name): void
|
||||
{
|
||||
if (!preg_match('/^[a-z_0-9]+$/i', $name)) {
|
||||
throw new \InvalidArgumentException('invalid identifier');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Table `backup_setting` (name/value pairs; the runner writes its state_* values into it as well).
|
||||
*/
|
||||
class SettingsRepository extends Repository
|
||||
{
|
||||
/** @return array<string, string> name => value of the stored settings */
|
||||
public function load(): array
|
||||
{
|
||||
return $this->query("SELECT `name`, `value` FROM `backup_setting`")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
/** Stores the given values (name => value) */
|
||||
public function save(array $values): void
|
||||
{
|
||||
$this->transaction(function () use ($values): void {
|
||||
foreach ($values as $name => $value) {
|
||||
$this->query("INSERT INTO `backup_setting` (`name`, `value`) VALUES (:name, :value)
|
||||
ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)", ['name' => (string)$name, 'value' => (string)$value]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Repository;
|
||||
|
||||
use Mailcow\Backup\Model\Snapshot;
|
||||
use Mailcow\Core\Repository;
|
||||
|
||||
/**
|
||||
* Table `backup_snapshot`: the runner's cache of the repository's snapshots (read only here).
|
||||
*/
|
||||
class SnapshotRepository extends Repository
|
||||
{
|
||||
private const COLUMNS = "`id`, `short_id`, UNIX_TIMESTAMP(`time`) AS `time`, `hostname`, `tags`, `summary`, `domains`";
|
||||
|
||||
/** By id or short id, with its content (the mailboxes in it) */
|
||||
public function find(string $id): ?Snapshot
|
||||
{
|
||||
$row = $this->query("SELECT " . self::COLUMNS . ", `content` FROM `backup_snapshot` WHERE `id` = :id OR `short_id` = :short_id ORDER BY `id` = :exact DESC LIMIT 1",
|
||||
['id' => $id, 'short_id' => $id, 'exact' => $id])->fetch();
|
||||
return is_array($row) ? Snapshot::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/** @return Snapshot[] newest first; the content (the mailboxes in it) only with $withContent */
|
||||
public function all(bool $withContent = false): array
|
||||
{
|
||||
return array_map(fn(array $row): Snapshot => Snapshot::fromRow($row),
|
||||
$this->query("SELECT " . self::COLUMNS . ($withContent ? ", `content`" : "") . " FROM `backup_snapshot` ORDER BY `time` DESC, `id`")->fetchAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Service;
|
||||
|
||||
use Mailcow\Backup\Legacy\LegacyDomain;
|
||||
use Mailcow\Backup\Model\Job;
|
||||
use Mailcow\Backup\Repository\JobRepository;
|
||||
use Mailcow\Backup\Repository\RestoreRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Action `restore_domain`: recreates a domain deleted since the snapshot from its export (`domain_data`), before its
|
||||
* mailboxes are restored. Only for running restores an admin requested while the domain did not exist
|
||||
* (params.create_domain).
|
||||
*
|
||||
* Creates the domain like the legacy domain add (row, tags, domain map, external send-as rights for it dropped, SOGo
|
||||
* restarted so it knows the domain), unless it exists by now. Then adds what is missing: alias domains, DKIM keys, rate
|
||||
* limit, spam settings, BCC maps, footer, MTA-STS, domain admin assignments of accounts that still exist, groups (no
|
||||
* members, send-as rights or shares; members follow with the mailboxes), mail policies and retention rules with their
|
||||
* group assignments (matched by name).
|
||||
*/
|
||||
class DomainRestoreService
|
||||
{
|
||||
private const NEW_ROW = ['id', 'created', 'modified'];
|
||||
private const POLICY_LISTS = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
|
||||
private const RATE_LIMIT = '/^\d+ \/ 1[smhd]$/';
|
||||
|
||||
public function __construct(private RestoreRepository $repo, private JobRepository $jobs, private GroupMembers $groups,
|
||||
private LegacyDomain $legacy)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{domain: array{added: int, skipped: int, errors: string[], created: int}}
|
||||
* @throws UserError
|
||||
*/
|
||||
public function restore(mixed $jobId, mixed $data): array
|
||||
{
|
||||
$id = Input::intInRange($jobId, 1, PHP_INT_MAX);
|
||||
$job = ($id === null) ? null : $this->jobs->find($id);
|
||||
if ($job === null || $job->type !== 'restore' || $job->state !== 'running') {
|
||||
throw new UserError('backup_job_not_running');
|
||||
}
|
||||
if (!$job->createsDomain() || ($job->params['requested_role'] ?? '') !== 'admin') {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$domain = strtolower((string)$job->domain);
|
||||
if (!is_array($data) || !is_array($data['domain'] ?? null) || strtolower((string)($data['domain']['domain'] ?? '')) !== $domain) {
|
||||
throw new UserError('backup_export_invalid');
|
||||
}
|
||||
$result = $this->repo->locked($job->id . ':domain', fn(): array => $this->restoreDomain($job, $domain, $data));
|
||||
return is_array($result) ? ['domain' => $result] : throw new UserError('backup_job_busy');
|
||||
}
|
||||
|
||||
private function restoreDomain(Job $job, string $domain, array $data): array
|
||||
{
|
||||
$result = ['added' => 0, 'skipped' => 0, 'errors' => [], 'created' => 0];
|
||||
if (!empty($this->repo->rows('alias_domain', ['alias_domain' => $domain]))) {
|
||||
$result['errors'][] = "$domain is an alias domain now";
|
||||
return $result;
|
||||
}
|
||||
if (!empty($this->repo->rows('domain', ['domain' => $domain]))) {
|
||||
// created meanwhile: only what is missing is added
|
||||
$result['skipped']++;
|
||||
} else {
|
||||
$this->createDomain($domain, $data, $result);
|
||||
}
|
||||
$this->repo->inTransaction(function () use ($domain, $data, &$result): void {
|
||||
$this->aliasDomains($domain, $data, $result);
|
||||
$this->domainSettings($domain, $data, $result);
|
||||
});
|
||||
$this->redisSettings($domain, $data, $result);
|
||||
$groupIds = $this->createGroups($domain, $data, $result);
|
||||
$this->repo->inTransaction(function () use ($domain, $data, $groupIds, &$result): void {
|
||||
$this->rules($domain, $data, 'mail_policies', 'mail_policy', 'mail_policy_member', 'policy_id', $groupIds, $result);
|
||||
$this->rules($domain, $data, 'retention_rules', 'retention_rule', 'retention_rule_member', 'rule_id', $groupIds, $result);
|
||||
});
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** The domain row, its tags and the effects of the legacy domain add */
|
||||
private function createDomain(string $domain, array $data, array &$result): void
|
||||
{
|
||||
// the domain keeps its creation time: domain admins may use the snapshots made after it
|
||||
$row = array_diff_key(RestoreService::decode($data['domain']), array_flip(['modified']));
|
||||
$relayhost = (string)($row['relayhost'] ?? '0');
|
||||
if ($relayhost !== '0' && $relayhost !== '' && empty($this->repo->rows('relayhosts', ['id' => (int)$relayhost]))) {
|
||||
$row['relayhost'] = '0';
|
||||
$result['errors'][] = 'relayhost: does not exist';
|
||||
}
|
||||
$this->repo->inTransaction(function () use ($domain, $row, $data): void {
|
||||
$this->repo->insert('domain', ['domain' => $domain] + $row);
|
||||
$this->repo->dropExternalSenderAcl($domain);
|
||||
foreach (array_unique(array_filter(array_map(fn(mixed $tag): string => is_scalar($tag) ? trim((string)$tag) : '', (array)($data['tags'] ?? [])), 'strlen')) as $tag) {
|
||||
$this->repo->insert('tags_domain', ['domain' => $domain, 'tag_name' => $tag]);
|
||||
}
|
||||
});
|
||||
$result['added']++;
|
||||
$result['created'] = 1;
|
||||
$this->effect($result, 'domain map', fn() => $this->legacy->addDomainMap($domain));
|
||||
$error = $this->legacy->restartSogo();
|
||||
if ($error !== null) {
|
||||
$result['errors'][] = 'SOGo restart failed: ' . $error;
|
||||
}
|
||||
}
|
||||
|
||||
private function aliasDomains(string $domain, array $data, array &$result): void
|
||||
{
|
||||
foreach (self::rows($data, 'alias_domains') as $row) {
|
||||
$alias = strtolower(trim((string)($row['alias_domain'] ?? '')));
|
||||
if ($alias === '' || $this->repo->domainExists($alias)) {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$this->repo->insert('alias_domain', ['alias_domain' => $alias, 'target_domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
|
||||
$this->repo->dropExternalSenderAcl($alias);
|
||||
$this->effect($result, "domain map of $alias", fn() => $this->legacy->addDomainMap($alias));
|
||||
$result['added']++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Spam settings, BCC maps, footer, MTA-STS and domain admins of the domain that are missing */
|
||||
private function domainSettings(string $domain, array $data, array &$result): void
|
||||
{
|
||||
foreach (self::rows($data, 'filterconf') as $row) {
|
||||
$option = (string)($row['option'] ?? '');
|
||||
$value = (string)($row['value'] ?? '');
|
||||
$current = $this->repo->rows('filterconf', ['object' => $domain]);
|
||||
$present = in_array($option, ['whitelist_from', 'blacklist_from'], true)
|
||||
? array_filter($current, fn(array $c): bool => in_array($c['option'], ['whitelist_from', 'blacklist_from'], true) && strtolower((string)$c['value']) === strtolower($value))
|
||||
: array_filter($current, fn(array $c): bool => $c['option'] === $option && (in_array($option, ['lowspamlevel', 'highspamlevel'], true) || (string)$c['value'] === $value));
|
||||
if ($option === '' || !empty($present)) {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$this->repo->insert('filterconf', ['object' => $domain, 'option' => $option, 'value' => $value]);
|
||||
$result['added']++;
|
||||
}
|
||||
foreach (self::rows($data, 'bcc_maps') as $row) {
|
||||
$localDest = strtolower((string)($row['local_dest'] ?? ''));
|
||||
if (!in_array($localDest, [$domain, '@' . $domain], true) || !filter_var((string)($row['bcc_dest'] ?? ''), FILTER_VALIDATE_EMAIL)
|
||||
|| !in_array($row['type'] ?? '', ['sender', 'rcpt'], true)) {
|
||||
$result['errors'][] = "BCC map $localDest: invalid";
|
||||
} elseif (!empty($this->repo->rows('bcc_maps', ['local_dest' => $localDest, 'type' => $row['type']]))) {
|
||||
$result['skipped']++;
|
||||
} else {
|
||||
$this->repo->insert('bcc_maps', ['domain' => $domain] + array_diff_key($row, array_flip(self::NEW_ROW)));
|
||||
$result['added']++;
|
||||
}
|
||||
}
|
||||
foreach (['footer' => 'domain_wide_footer', 'mta_sts' => 'mta_sts'] as $key => $table) {
|
||||
if (!is_array($data[$key] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($this->repo->rows($table, ['domain' => $domain]))) {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$this->repo->insert($table, ['domain' => $domain] + array_diff_key(RestoreService::decode($data[$key]), array_flip(['created', 'modified'])));
|
||||
$result['added']++;
|
||||
}
|
||||
foreach (self::rows($data, 'domain_admins') as $row) {
|
||||
$username = (string)($row['username'] ?? '');
|
||||
if (empty($this->repo->rows('admin', ['username' => $username, 'superadmin' => 0]))) {
|
||||
$result['errors'][] = "domain admin $username: does not exist";
|
||||
} elseif (!empty($this->repo->rows('domain_admins', ['username' => $username, 'domain' => $domain]))) {
|
||||
$result['skipped']++;
|
||||
} else {
|
||||
$this->repo->insert('domain_admins', ['username' => $username, 'domain' => $domain, 'active' => (int)($row['active'] ?? 1)]);
|
||||
$result['added']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** DKIM keys of the domain and its alias domains, and the rate limit of the domain (Redis), when missing */
|
||||
private function redisSettings(string $domain, array $data, array &$result): void
|
||||
{
|
||||
$domains = array_merge([$domain], $this->repo->aliasDomainsOf($domain));
|
||||
foreach (self::rows($data, 'dkim') as $key) {
|
||||
$name = strtolower((string)($key['domain'] ?? ''));
|
||||
$selector = (string)($key['selector'] ?? '');
|
||||
if (!in_array($name, $domains, true)) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9._-]{1,63}$/i', $selector) || (string)($key['pubkey'] ?? '') === '' || (string)($key['privkey'] ?? '') === '') {
|
||||
$result['errors'][] = "DKIM key of $name: invalid";
|
||||
continue;
|
||||
}
|
||||
$this->effect($result, "DKIM key of $name", function () use ($name, $selector, $key, &$result): void {
|
||||
if ($this->legacy->dkim($name) !== null) {
|
||||
$result['skipped']++;
|
||||
return;
|
||||
}
|
||||
$this->legacy->addDkim($name, $selector, (string)$key['pubkey'], (string)$key['privkey']);
|
||||
$result['added']++;
|
||||
});
|
||||
}
|
||||
$rateLimit = $data['ratelimit'] ?? null;
|
||||
if (is_string($rateLimit) && preg_match(self::RATE_LIMIT, $rateLimit)) {
|
||||
$this->effect($result, 'rate limit', function () use ($domain, $rateLimit, &$result): void {
|
||||
if (isset($this->legacy->rateLimits([$domain])[$domain])) {
|
||||
$result['skipped']++;
|
||||
return;
|
||||
}
|
||||
$this->legacy->setRateLimit($domain, $rateLimit);
|
||||
$result['added']++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups of the domain missing by name, through the group module (address, flags, priority, IdP link)
|
||||
*
|
||||
* @return array<int, int> id in the backup => id now, of all groups found by name
|
||||
*/
|
||||
private function createGroups(string $domain, array $data, array &$result): array
|
||||
{
|
||||
$ids = [];
|
||||
$admin = new Context('admin', 'backup', [], true);
|
||||
foreach (self::rows($data, 'groups') as $row) {
|
||||
$name = (string)($row['name'] ?? '');
|
||||
$current = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => $name])[0] ?? null;
|
||||
if ($current !== null) {
|
||||
$ids[(int)($row['id'] ?? 0)] = (int)$current['id'];
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$input = ['domain' => $domain, 'name' => $name, 'description' => (string)($row['description'] ?? '')];
|
||||
foreach (['sender_allowed', 'sogo_visible', 'internal', 'active', 'priority'] as $key) {
|
||||
if (isset($row[$key])) {
|
||||
$input[$key] = (int)$row[$key];
|
||||
}
|
||||
}
|
||||
foreach (['address', 'idp_ref'] as $key) {
|
||||
if (($row[$key] ?? null) !== null && $row[$key] !== '') {
|
||||
$input[$key] = (string)$row[$key];
|
||||
}
|
||||
}
|
||||
try {
|
||||
$ids[(int)($row['id'] ?? 0)] = $this->groups->create($admin, $input);
|
||||
$result['added']++;
|
||||
} catch (UserError $e) {
|
||||
$result['errors'][] = "group $name: " . $e->key;
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mail policies or retention rules missing by name, with their group assignments; group:<id> entries of policies
|
||||
* follow the groups' new ids
|
||||
*/
|
||||
private function rules(string $domain, array $data, string $key, string $table, string $member, string $column, array $groupIds, array &$result): void
|
||||
{
|
||||
foreach (self::rows($data, $key) as $row) {
|
||||
$name = (string)($row['name'] ?? '');
|
||||
if (!empty($this->repo->rows($table, ['domain' => $domain, 'name' => $name]))) {
|
||||
$result['skipped']++;
|
||||
continue;
|
||||
}
|
||||
$values = array_diff_key($row, array_flip([...self::NEW_ROW, 'group_members']));
|
||||
foreach (self::POLICY_LISTS as $list) {
|
||||
if (isset($values[$list])) {
|
||||
$values[$list] = json_encode($this->remapGroups((string)$values[$list], $groupIds, "$name ($list)", $result));
|
||||
}
|
||||
}
|
||||
$id = $this->repo->insert($table, ['domain' => $domain, 'name' => $name] + $values);
|
||||
foreach ((array)($row['group_members'] ?? []) as $groupName) {
|
||||
$group = $this->repo->rows('mailbox_group', ['domain' => $domain, 'name' => (string)$groupName])[0] ?? null;
|
||||
if ($group === null) {
|
||||
$result['errors'][] = "$name: group $groupName does not exist";
|
||||
continue;
|
||||
}
|
||||
$this->repo->insert($member, [$column => $id, 'group_id' => (int)$group['id']]);
|
||||
}
|
||||
$result['added']++;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] entries of a JSON list with group:<id> of the backup turned into the group's id now */
|
||||
private function remapGroups(string $json, array $groupIds, string $what, array &$result): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach ((array)json_decode($json, true) as $entry) {
|
||||
if (!is_string($entry)) {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^group:(\d+)$/', $entry, $match)) {
|
||||
if (!isset($groupIds[(int)$match[1]])) {
|
||||
$result['errors'][] = "$what: $entry dropped (group does not exist)";
|
||||
continue;
|
||||
}
|
||||
$entry = 'group:' . $groupIds[(int)$match[1]];
|
||||
}
|
||||
$entries[] = $entry;
|
||||
}
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** A Redis or dockerapi step: a failure is reported and the rest goes on */
|
||||
private function effect(array &$result, string $what, callable $work): void
|
||||
{
|
||||
try {
|
||||
$work();
|
||||
} catch (Throwable) {
|
||||
$result['errors'][] = "$what: not stored (Redis)";
|
||||
}
|
||||
}
|
||||
|
||||
private static function rows(array $data, string $key): array
|
||||
{
|
||||
return array_values(array_filter((array)($data[$key] ?? []), 'is_array'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Service;
|
||||
|
||||
use Mailcow\Backup\Legacy\LegacyDomain;
|
||||
use Mailcow\Backup\Repository\ExportRepository;
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Export of a domain for the backup (/mailcow/export/<domain>.json in a snapshot), in pages of mailboxes the runner
|
||||
* merges into one file. The first page holds version, db_version, domain, domain_data (what recreating the domain
|
||||
* needs), the aliases and `mailbox_aliases` (username => the aliases of any domain whose goto names the mailbox,
|
||||
* without its own alias and group addresses; the runner moves them into the mailbox sections when it reads the file);
|
||||
* every page a part of `mailboxes` and `next_after`, the last username of the page to ask for the next one with
|
||||
* (keyset paging; null after the last page).
|
||||
*
|
||||
* Per mailbox every row a restore needs, with all columns. Password hashes, DKIM keys and the secrets of app
|
||||
* passwords, 2FA and sync jobs are part of it on purpose; only the runner gets it (backup.php with the token). Binary
|
||||
* values (fido2) are written as {"base64": "..."}. Beside the keys of the spec, a mailbox has `own_alias` (its alias
|
||||
* row: forwarding with or without a copy), `aliases` (empty, see mailbox_aliases) and `ratelimit` (Redis).
|
||||
*/
|
||||
class ExportService
|
||||
{
|
||||
public const VERSION = 1;
|
||||
public const PAGE_SIZE = 500;
|
||||
/** Lists of a mailbox: key => [table, column with the username] */
|
||||
private const MAILBOX_ROWS = [
|
||||
'sender_acl' => ['sender_acl', 'logged_in_as'],
|
||||
'sender_acl_granted' => ['sender_acl', 'send_as'],
|
||||
'sieve_filters' => ['sieve_filters', 'username'],
|
||||
'filterconf' => ['filterconf', 'object'],
|
||||
'spamalias' => ['spamalias', 'goto'],
|
||||
'bcc_maps' => ['bcc_maps', 'local_dest'],
|
||||
'app_passwd' => ['app_passwd', 'mailbox'],
|
||||
'tfa' => ['tfa', 'username'],
|
||||
// FIDO2 keys of mailbox users (passwordless login on the user page), like those of admins
|
||||
'fido2' => ['fido2', 'username'],
|
||||
'identity_link' => ['identity_link', 'username'],
|
||||
];
|
||||
/** Single rows of a mailbox: key => [table, column with the username] */
|
||||
private const MAILBOX_ROW = [
|
||||
'user_acl' => ['user_acl', 'username'],
|
||||
'pushover' => ['pushover', 'username'],
|
||||
];
|
||||
|
||||
public function __construct(private ExportRepository $repo, private LegacyDomain $legacy)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return string[] all domains */
|
||||
public function domains(): array
|
||||
{
|
||||
return array_values(array_map('strval', $this->repo->domains()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?string $after the last username of the previous page (keyset paging), null for the first page
|
||||
* @throws UserError domain_not_found
|
||||
*/
|
||||
public function export(string $domain, ?string $after = null, int $pageSize = self::PAGE_SIZE): array
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
$row = ($domain === '') ? null : ($this->repo->rows('domain', 'domain', $domain)[0] ?? null);
|
||||
if ($row === null) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars($domain)]);
|
||||
}
|
||||
$rows = $this->repo->mailboxesAfter($domain, strtolower($after ?? ''), $pageSize + 1);
|
||||
$more = count($rows) > $pageSize;
|
||||
$rows = array_slice($rows, 0, $pageSize);
|
||||
$export = ['version' => self::VERSION, 'domain' => $domain];
|
||||
if ($after === null) {
|
||||
$export += [
|
||||
'db_version' => $this->repo->dbVersion(),
|
||||
'domain_data' => $this->domainData($domain, $row),
|
||||
'aliases' => array_map(fn(array $alias): array => self::encode($alias), $this->repo->aliases($domain)),
|
||||
'mailbox_aliases' => $this->mailboxAliases($domain) ?: new \stdClass(),
|
||||
];
|
||||
}
|
||||
$export['mailboxes'] = $this->mailboxes($domain, $rows) ?: new \stdClass();
|
||||
$export['next_after'] = ($more && !empty($rows)) ? strtolower((string)end($rows)['username']) : null;
|
||||
return $export;
|
||||
}
|
||||
|
||||
/** What recreating the domain needs: its row and settings, alias domains, DKIM keys, groups, policies, rules */
|
||||
private function domainData(string $domain, array $row): array
|
||||
{
|
||||
$aliasDomains = $this->repo->rows('alias_domain', 'target_domain', $domain);
|
||||
$dkim = [];
|
||||
foreach (array_merge([$domain], array_map(fn(array $alias): string => strtolower((string)$alias['alias_domain']), $aliasDomains)) as $name) {
|
||||
$key = $this->legacy->dkim($name);
|
||||
if ($key !== null) {
|
||||
$dkim[] = ['domain' => $name] + $key;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'domain' => self::encode($row),
|
||||
'tags' => array_map(fn(array $tag): string => (string)$tag['tag_name'], $this->repo->rows('tags_domain', 'domain', $domain)),
|
||||
'alias_domains' => $aliasDomains,
|
||||
'dkim' => $dkim,
|
||||
'ratelimit' => $this->legacy->rateLimits([$domain])[$domain] ?? null,
|
||||
'filterconf' => $this->repo->rows('filterconf', 'object', $domain),
|
||||
'bcc_maps' => $this->repo->domainBccMaps($domain),
|
||||
'footer' => $this->repo->rows('domain_wide_footer', 'domain', $domain)[0] ?? null,
|
||||
'mta_sts' => $this->repo->rows('mta_sts', 'domain', $domain)[0] ?? null,
|
||||
'domain_admins' => $this->repo->rows('domain_admins', 'domain', $domain),
|
||||
'groups' => $this->repo->rows('mailbox_group', 'domain', $domain),
|
||||
'mail_policies' => $this->repo->domainRules('mail_policy', 'mail_policy_member', 'policy_id', $domain),
|
||||
'retention_rules' => $this->repo->domainRules('retention_rule', 'retention_rule_member', 'rule_id', $domain),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, array> username => its section */
|
||||
private function mailboxes(string $domain, array $rows): array
|
||||
{
|
||||
$mailboxes = [];
|
||||
foreach ($rows as $row) {
|
||||
$mailboxes[strtolower((string)$row['username'])] = [
|
||||
'mailbox' => self::encode($row) + ['tags' => []],
|
||||
'user_acl' => null,
|
||||
'pushover' => null,
|
||||
'ratelimit' => null,
|
||||
'own_alias' => null,
|
||||
'aliases' => [],
|
||||
'groups' => [],
|
||||
'mail_policies' => [],
|
||||
'retention_rules' => [],
|
||||
'imapsync' => [],
|
||||
] + array_fill_keys(array_keys(self::MAILBOX_ROWS), []);
|
||||
}
|
||||
$usernames = array_map('strval', array_keys($mailboxes));
|
||||
if (empty($usernames)) {
|
||||
return [];
|
||||
}
|
||||
// the mailbox of the page a row belongs to, null for anything else
|
||||
$owner = fn(array $row, string $column): ?string => isset($mailboxes[strtolower((string)$row[$column])]) ? strtolower((string)$row[$column]) : null;
|
||||
foreach ($this->repo->rowsOfMailboxes('tags_mailbox', 'username', $usernames) as $row) {
|
||||
if (($username = $owner($row, 'username')) !== null) {
|
||||
$mailboxes[$username]['mailbox']['tags'][] = (string)$row['tag_name'];
|
||||
}
|
||||
}
|
||||
foreach (self::MAILBOX_ROW as $key => [$table, $column]) {
|
||||
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
|
||||
if (($username = $owner($row, $column)) !== null) {
|
||||
$mailboxes[$username][$key] = self::encode($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (self::MAILBOX_ROWS as $key => [$table, $column]) {
|
||||
foreach ($this->repo->rowsOfMailboxes($table, $column, $usernames) as $row) {
|
||||
if (($username = $owner($row, $column)) !== null) {
|
||||
$mailboxes[$username][$key][] = self::encode($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($this->legacy->rateLimits($usernames) as $username => $value) {
|
||||
if (isset($mailboxes[strtolower((string)$username)])) {
|
||||
$mailboxes[strtolower((string)$username)]['ratelimit'] = $value;
|
||||
}
|
||||
}
|
||||
foreach ($this->repo->groupMemberships($usernames) as $row) {
|
||||
if (($username = $owner($row, 'username')) !== null) {
|
||||
$mailboxes[$username]['groups'][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain'],
|
||||
'source' => (string)$row['source']];
|
||||
}
|
||||
}
|
||||
foreach (['mail_policies' => ['mail_policy', 'mail_policy_member', 'policy_id'], 'retention_rules' => ['retention_rule', 'retention_rule_member', 'rule_id']]
|
||||
as $key => [$table, $member, $column]) {
|
||||
foreach ($this->repo->assignments($table, $member, $column, $usernames) as $row) {
|
||||
if (($username = $owner($row, 'username')) !== null) {
|
||||
$mailboxes[$username][$key][] = ['id' => (int)$row['id'], 'name' => (string)$row['name'], 'domain' => (string)$row['domain']];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($this->repo->syncJobs($usernames) as $row) {
|
||||
if (($username = $owner($row, 'user2')) !== null) {
|
||||
$mailboxes[$username]['imapsync'][] = self::encode($row);
|
||||
}
|
||||
}
|
||||
foreach ($this->repo->rowsOfMailboxes('alias', 'address', $usernames) as $row) {
|
||||
if (($username = $owner($row, 'address')) !== null) {
|
||||
$mailboxes[$username]['own_alias'] = self::encode($row);
|
||||
}
|
||||
}
|
||||
return $mailboxes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array[]> username => aliases of any domain whose goto names the mailbox (once per domain: the
|
||||
* alias table is scanned, not once per page)
|
||||
*/
|
||||
private function mailboxAliases(string $domain): array
|
||||
{
|
||||
$usernames = array_fill_keys(array_map(fn(mixed $username): string => strtolower((string)$username), $this->repo->usernames($domain)), true);
|
||||
$aliases = [];
|
||||
foreach ($this->repo->aliasesPointingTo($domain) as $row) {
|
||||
if ($row['group_id'] !== null) {
|
||||
continue;
|
||||
}
|
||||
$address = strtolower((string)$row['address']);
|
||||
foreach (array_unique(array_map('strtolower', self::gotos((string)$row['goto']))) as $goto) {
|
||||
if ($goto !== $address && isset($usernames[$goto])) {
|
||||
$aliases[$goto][] = self::encode($row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $aliases;
|
||||
}
|
||||
|
||||
/** @return string[] addresses of a goto list */
|
||||
public static function gotos(string $goto): array
|
||||
{
|
||||
return array_values(array_filter(array_map('trim', explode(',', $goto)), 'strlen'));
|
||||
}
|
||||
|
||||
/** Binary values (not UTF-8) as {"base64": "..."}, so the row survives JSON */
|
||||
public static function encode(array $row): array
|
||||
{
|
||||
foreach ($row as $column => $value) {
|
||||
if (is_string($value) && !mb_check_encoding($value, 'UTF-8')) {
|
||||
$row[$column] = ['base64' => base64_encode($value)];
|
||||
}
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Service;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailboxGroup\Repository\GroupRepository;
|
||||
use Mailcow\MailboxGroup\Repository\LookupRepository;
|
||||
use Mailcow\MailboxGroup\Service\ChangeEffects;
|
||||
use Mailcow\MailboxGroup\Service\GroupService;
|
||||
use Mailcow\MailboxGroup\Service\SharingService;
|
||||
use Mailcow\MailboxGroup\Service\TemplateService;
|
||||
|
||||
/**
|
||||
* Group writes of a restore through the group module (addresses, send-as rights, shares and caches follow), as the
|
||||
* requester of the restore: an admin, or the domain admin with his rights now (GroupService::canEdit). The module is
|
||||
* wired without group templates (Legacy\NoTemplates).
|
||||
*/
|
||||
class GroupMembers
|
||||
{
|
||||
public function __construct(private GroupRepository $repo, private LookupRepository $lookup, private TemplateService $templates,
|
||||
private ChangeEffects $effects, private SharingService $sharing)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws UserError e.g. access_denied when the group is read-only for the requester */
|
||||
public function add(Context $context, int $groupId, string $username): void
|
||||
{
|
||||
$this->service($context)->update($groupId, ['add_members' => [$username]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a group (restore of a deleted domain); members follow with the mailboxes.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(Context $context, array $input): int
|
||||
{
|
||||
return (int)$this->service($context)->create($input)->id;
|
||||
}
|
||||
|
||||
protected function service(Context $context): GroupService
|
||||
{
|
||||
return new GroupService($this->repo, $this->lookup, $this->templates, $this->effects, $this->sharing, $context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Mailcow\Backup\Model\Job;
|
||||
use Mailcow\Backup\Model\Settings;
|
||||
use Mailcow\Backup\Model\Snapshot;
|
||||
use Mailcow\Backup\Repository\JobRepository;
|
||||
use Mailcow\Backup\Repository\SnapshotRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Jobs of the backup runners, the snapshots with their content and the status page.
|
||||
*
|
||||
* Backup, check, prune and init jobs: admins (not read-only API keys) queue them, one of each type at a time.
|
||||
* Restores: admins, and domain admins for mailboxes or a domain they administer now. Mailboxes and domains deleted
|
||||
* since the snapshot are created from it (deleted domains by admins only). A component a domain admin could not
|
||||
* manage himself (e.g. sync jobs without the `syncjobs` ACL) is refused; his ACL and domains are stored with the job,
|
||||
* so the merge and the runners apply the same limits.
|
||||
* Reading: admins see every job and snapshot; domain admins the restores of their domains and, of each snapshot made
|
||||
* after their domain was created, their domains in it.
|
||||
*/
|
||||
class JobService
|
||||
{
|
||||
/**
|
||||
* Restore components in their order. mails, imap_acl, sieve_activate and the Sieve scripts of filters are restored by
|
||||
* the dovecot runner, sogo by the restic runner, the others by RestoreService.
|
||||
*/
|
||||
public const COMPONENTS = ['mails', 'imap_acl', 'sogo', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules',
|
||||
'filters', 'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'settings', 'password'];
|
||||
/** Components that replace current data with the backup's (all others only add what is missing) */
|
||||
public const OVERWRITE = ['sogo', 'settings', 'password'];
|
||||
/** Components that need the mails: not in snapshots without them (RESTIC_BACKUP_MODE=config) */
|
||||
public const MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate'];
|
||||
public const MAIL_TARGETS = ['folder', 'original'];
|
||||
/** Domain admin ACL a component needs */
|
||||
private const COMPONENT_ACL = [
|
||||
'groups' => 'mailbox_groups',
|
||||
'mail_policies' => 'mail_policies',
|
||||
'retention_rules' => 'retention',
|
||||
'filters' => 'filters',
|
||||
'sieve_activate' => 'filters',
|
||||
'spam' => 'spam_policy',
|
||||
'bcc' => 'bcc_maps',
|
||||
'syncjobs' => 'syncjobs',
|
||||
'app_passwords' => 'app_passwds',
|
||||
];
|
||||
/** Components that only work together with another one */
|
||||
private const REQUIRES = ['sieve_activate' => 'filters'];
|
||||
/** Domain admin ACLs the merge respects, stored with the job */
|
||||
private const RESTORE_ACL = ['filters', 'mailbox_groups', 'mail_policies', 'retention', 'spam_policy', 'bcc_maps', 'syncjobs',
|
||||
'app_passwds', 'protocol_access', 'mailbox_relayhost', 'unlimited_quota', 'sogo_access', 'pushover', 'extend_sender_acl',
|
||||
'alias_external_goto', 'ratelimit', 'tls_policy', 'quarantine', 'quarantine_notification', 'quarantine_category'];
|
||||
private const MAX_MAILBOXES = 1000;
|
||||
private const LIST_LIMIT = 100;
|
||||
private const MAX_LIST = 1000;
|
||||
/** Mailboxes a search returns at most */
|
||||
private const SEARCH_LIMIT = 500;
|
||||
/** The runner reports every 10 seconds */
|
||||
private const RUNNER_TIMEOUT = 120;
|
||||
/** @var array<string, array{bool, ?int}> domain => [access of the domain admin, creation time now], per request */
|
||||
private array $domainState = [];
|
||||
|
||||
public function __construct(private JobRepository $jobs, private SnapshotRepository $snapshots, private SettingsService $settings,
|
||||
private Context $context, private string $timezone = 'UTC')
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $type only jobs of this type ('' or null = all; an unknown type finds nothing)
|
||||
* @return Job[] newest first, at most $limit (0 = LIST_LIMIT)
|
||||
*/
|
||||
public function list(int $limit = 0, mixed $type = null): array
|
||||
{
|
||||
$type = Input::text($type);
|
||||
if ($type !== '' && !in_array($type, Job::TYPES, true)) {
|
||||
return [];
|
||||
}
|
||||
return $this->jobs->visible($this->context, ($limit > 0) ? min($limit, self::MAX_LIST) : self::LIST_LIMIT, ($type === '') ? null : $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry of the API. Domain admins get it restricted: e-mail addresses outside the job's domain and its alias domains
|
||||
* and repository details (URLs, restic repository strings, paths of the repository) are shown as "…".
|
||||
*/
|
||||
public function view(Job $job, bool $withLog = false): array
|
||||
{
|
||||
$entry = $job->toArray($withLog);
|
||||
if ($this->context->hasAdminRights()) {
|
||||
return $entry;
|
||||
}
|
||||
$domains = array_merge([strtolower($job->domain)], $this->jobs->aliasDomainsOf(strtolower($job->domain)));
|
||||
foreach (['result', 'error', 'log'] as $key) {
|
||||
if (isset($entry[$key])) {
|
||||
$entry[$key] = self::restricted($entry[$key], $domains);
|
||||
}
|
||||
}
|
||||
// the account of an admin who requested the restore stays hidden, the UI shows "Administrator"
|
||||
if (($job->params['requested_role'] ?? '') !== 'domainadmin') {
|
||||
$entry['requested_by'] = '';
|
||||
if (isset($entry['log'])) {
|
||||
$entry['log'] = str_replace('(requested by ' . $job->requestedBy . ')', '(requested by an administrator)', $entry['log']);
|
||||
}
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/** The job with its log, null when it does not exist or the caller may not see it */
|
||||
public function get(int $id): ?Job
|
||||
{
|
||||
$job = $this->jobs->find($id, true);
|
||||
return ($job !== null && $this->visible($job)) ? $job : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a backup, check, prune or init job (admins).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function create(array $input): Job
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$type = Input::text($input['type'] ?? null);
|
||||
if (!in_array($type, Job::MANUAL_TYPES, true)) {
|
||||
throw new UserError('backup_job_type_invalid', [htmlspecialchars((string)$type)]);
|
||||
}
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings->configured()) {
|
||||
throw new UserError('backup_not_configured');
|
||||
}
|
||||
if ($type === 'backup' && $settings->get('password_confirmed') !== '1') {
|
||||
throw new UserError('backup_password_not_confirmed');
|
||||
}
|
||||
$job = new Job($type);
|
||||
$job->requestedBy = (string)$this->context->username;
|
||||
$this->jobs->insertExclusive($job, function (array $active) use ($type): void {
|
||||
foreach ($active as $other) {
|
||||
if ($other->type === $type) {
|
||||
throw new UserError('backup_job_active', [$type]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a restore: {snapshot, scope: mailbox|domain, mailboxes | domain, components, mail_target: folder|original}.
|
||||
* All mailboxes belong to one domain. Mailboxes deleted since the snapshot are created from it (create_mailboxes);
|
||||
* an admin may restore a deleted domain (create_domain). Scope domain restores the snapshot's mailboxes of the
|
||||
* domain (the current ones for snapshots without content).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function restore(array $input): Job
|
||||
{
|
||||
$this->requireRestorer();
|
||||
if (!$this->settings->load()->configured()) {
|
||||
throw new UserError('backup_not_configured');
|
||||
}
|
||||
$snapshot = $this->snapshot($input['snapshot'] ?? null);
|
||||
$scope = Input::text($input['scope'] ?? 'mailbox');
|
||||
[$domain, $mailboxes] = match ($scope) {
|
||||
'domain' => $this->domainScope($snapshot, $input['domain'] ?? null),
|
||||
'mailbox' => $this->mailboxScope($snapshot, $input['mailboxes'] ?? null, $input['domain'] ?? null),
|
||||
default => throw new UserError('backup_scope_invalid'),
|
||||
};
|
||||
$components = $this->components($input['components'] ?? null);
|
||||
$missing = array_values(array_intersect($components, self::MAIL_COMPONENTS));
|
||||
if ($snapshot->mode() === 'config' && !empty($missing)) {
|
||||
throw new UserError('backup_components_no_mails', [htmlspecialchars(implode(', ', $missing))]);
|
||||
}
|
||||
$mailTarget = Input::text($input['mail_target'] ?? null) ?: 'folder';
|
||||
if (!in_array($mailTarget, self::MAIL_TARGETS, true)) {
|
||||
throw new UserError('backup_mail_target_invalid');
|
||||
}
|
||||
$job = new Job('restore');
|
||||
$job->step = 'files';
|
||||
$job->domain = $domain;
|
||||
$job->requestedBy = (string)$this->context->username;
|
||||
$job->params = [
|
||||
'snapshot' => $snapshot->id,
|
||||
'snapshot_time' => $snapshot->time,
|
||||
'scope' => $scope,
|
||||
'domain' => $domain,
|
||||
'mailboxes' => $mailboxes,
|
||||
'components' => $components,
|
||||
'mail_target' => $mailTarget,
|
||||
'requested_role' => $this->context->hasAdminRights() ? 'admin' : 'domainadmin',
|
||||
'create_domain' => (int)!$this->jobs->isDomain($domain),
|
||||
'create_mailboxes' => array_values(array_filter($mailboxes, fn(string $username): bool => $this->jobs->mailboxDomain($username) === null)),
|
||||
];
|
||||
if (!$this->context->hasAdminRights()) {
|
||||
$job->params['acl'] = array_combine(self::RESTORE_ACL, array_map(fn(string $acl): int => (int)$this->context->hasAcl($acl), self::RESTORE_ACL));
|
||||
$job->params['allowed_domains'] = array_values(array_unique(array_merge([$domain], $this->jobs->aliasDomainsOf($domain))));
|
||||
}
|
||||
$domainAdmin = !$this->context->hasAdminRights();
|
||||
$this->jobs->insertExclusive($job, function (array $active) use ($mailboxes, $domain, $domainAdmin): void {
|
||||
foreach ($active as $other) {
|
||||
if ($other->type !== 'restore') {
|
||||
continue;
|
||||
}
|
||||
// a domain admin has one restore per domain at a time
|
||||
if ($domainAdmin && strtolower($other->domain) === $domain) {
|
||||
throw new UserError('backup_restore_domain_active', [htmlspecialchars($domain)]);
|
||||
}
|
||||
$both = array_intersect($mailboxes, $other->mailboxes());
|
||||
if (!empty($both)) {
|
||||
throw new UserError('backup_restore_active', [htmlspecialchars((string)reset($both))]);
|
||||
}
|
||||
}
|
||||
});
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a queued or running job (the restic runner stops at its next check): admins any, domain admins the
|
||||
* restores of their domains that domain admins requested. A restore in the dovecot step (mails, mails_running) runs
|
||||
* to its end.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function cancel(int $id): Job
|
||||
{
|
||||
if ($this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$job = $this->jobs->find($id);
|
||||
// domain admins cancel only restores domain admins requested
|
||||
if ($job === null || !$this->visible($job) || (!$this->context->hasAdminRights() && ($job->params['requested_role'] ?? '') !== 'domainadmin')) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
if (!$job->isActive() || ($job->type === 'restore' && in_array($job->step, ['mails', 'mails_running'], true)) || !$this->jobs->cancel($id)) {
|
||||
throw new UserError('backup_job_not_cancellable', [$id]);
|
||||
}
|
||||
$job->state = 'cancelled';
|
||||
return $job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots, newest first. Domain admins get those holding one of their domains (see mayUse()), with id, short_id,
|
||||
* time and those domains.
|
||||
*/
|
||||
public function snapshots(): array
|
||||
{
|
||||
if ($this->context->hasAdminRights()) {
|
||||
return array_map(fn(Snapshot $snapshot): array => $snapshot->toArray(), $this->snapshots->all());
|
||||
}
|
||||
if (!$this->context->isDomainAdmin()) {
|
||||
return [];
|
||||
}
|
||||
$snapshots = [];
|
||||
// the domains with their creation time come from the snapshot row, the content is not needed here
|
||||
foreach ($this->snapshots->all() as $snapshot) {
|
||||
$domains = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
|
||||
if (!empty($domains)) {
|
||||
$snapshots[] = ['id' => $snapshot->id, 'short_id' => $snapshot->shortId, 'time' => $snapshot->time, 'domains' => $domains,
|
||||
'mode' => $snapshot->mode()];
|
||||
}
|
||||
}
|
||||
return $snapshots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains of a snapshot with their state now: GET backup-snapshot-content/<snapshot>. `available` 0: the snapshot has
|
||||
* no content (not made by this installation), the list comes from its manifest and only existing mailboxes can be restored. A domain
|
||||
* of the manifest missing in the content (its export failed) lists its current mailboxes as well.
|
||||
*
|
||||
* @return array{snapshot: string, time: int, available: int, domains: array[]}|array{} [] when not found or not visible
|
||||
*/
|
||||
public function content(mixed $snapshotId): array
|
||||
{
|
||||
$snapshot = $this->visibleSnapshot($snapshotId);
|
||||
if ($snapshot === null) {
|
||||
return [];
|
||||
}
|
||||
$visible = array_values(array_filter($snapshot->domainList(), fn(string $domain): bool => $this->mayUse($snapshot, $domain)));
|
||||
[$existing, $usernames] = $this->jobs->domainsAndUsernames($visible);
|
||||
$domains = [];
|
||||
foreach ($visible as $domain) {
|
||||
$current = $usernames[$domain] ?? [];
|
||||
$mailboxes = $snapshot->mailboxesOf($domain) ?? $current;
|
||||
$domains[] = ['domain' => $domain, 'exists' => (int)isset($existing[$domain]), 'mailboxes' => count($mailboxes),
|
||||
'missing' => count(array_diff_key($mailboxes, $current))];
|
||||
}
|
||||
return ['snapshot' => $snapshot->id, 'time' => $snapshot->time, 'available' => (int)($snapshot->content !== null), 'domains' => $domains];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search in a snapshot: GET backup-snapshot-search/<snapshot>/<term>. Matches domain names and mailboxes by local
|
||||
* part, address or name ("x@": local part, "@d": domain, "x@d": address), with the rules of content(); at most
|
||||
* SEARCH_LIMIT mailboxes. A matching domain comes with all its mailboxes.
|
||||
*
|
||||
* @return array{domains: array[], truncated: int}|array{} [] when the snapshot is not found or not visible
|
||||
*/
|
||||
public function search(mixed $snapshotId, mixed $term): array
|
||||
{
|
||||
$snapshot = $this->visibleSnapshot($snapshotId);
|
||||
$term = mb_strtolower((string)Input::text($term));
|
||||
if ($snapshot === null) {
|
||||
return [];
|
||||
}
|
||||
if (trim($term, '@') === '') {
|
||||
return ['domains' => [], 'truncated' => 0];
|
||||
}
|
||||
$domains = [];
|
||||
$hits = 0;
|
||||
$truncated = 0;
|
||||
[$local, $at, $domainTerm] = str_contains($term, '@') ? [strstr($term, '@', true), true, substr((string)strstr($term, '@'), 1)] : [$term, false, ''];
|
||||
foreach ($snapshot->domainList() as $domain) {
|
||||
if (!$this->mayUse($snapshot, $domain)) {
|
||||
continue;
|
||||
}
|
||||
$domainMatch = $at ? ($domainTerm !== '' && str_contains($domain, $domainTerm)) : str_contains($domain, $term);
|
||||
if ($at && $domainTerm !== '' && !$domainMatch) {
|
||||
continue;
|
||||
}
|
||||
$current = $this->jobs->mailboxNames($domain);
|
||||
$mailboxes = [];
|
||||
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
|
||||
$username = (string)$username;
|
||||
$part = (string)strstr($username, '@', true);
|
||||
$match = match (true) {
|
||||
// "x@d": the address, "x@": the local part, "@d": every mailbox of the matching domains
|
||||
$at && $local !== '' && $domainTerm !== '' => str_contains($username, $term),
|
||||
$at && $local !== '' => str_contains($part, $local),
|
||||
$at => true,
|
||||
default => $domainMatch || str_contains($username, $term) || str_contains(mb_strtolower($name), $term),
|
||||
};
|
||||
if (!$match) {
|
||||
continue;
|
||||
}
|
||||
if ($hits >= self::SEARCH_LIMIT) {
|
||||
$truncated = 1;
|
||||
break;
|
||||
}
|
||||
$hits++;
|
||||
$mailboxes[] = ['username' => $username, 'name' => $name, 'exists' => (int)isset($current[$username])];
|
||||
}
|
||||
// a domain without matching mailboxes only for a search by domain
|
||||
if (!empty($mailboxes) || ($domainMatch && !($at && $local !== '') && !$truncated)) {
|
||||
$domains[] = ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
|
||||
}
|
||||
if ($truncated) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ['domains' => $domains, 'truncated' => $truncated];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailboxes of a domain in a snapshot with their state now: GET backup-snapshot-content/<snapshot>/<domain>
|
||||
*
|
||||
* @return array{domain: string, exists: int, mailboxes: array[]}|array{} [] when not found or not visible
|
||||
*/
|
||||
public function domainContent(mixed $snapshotId, mixed $domain): array
|
||||
{
|
||||
$snapshot = $this->visibleSnapshot($snapshotId);
|
||||
$domain = strtolower((string)Input::text($domain));
|
||||
if ($snapshot === null || !$snapshot->hasDomain($domain) || !$this->mayUse($snapshot, $domain)) {
|
||||
return [];
|
||||
}
|
||||
$current = $this->jobs->mailboxNames($domain);
|
||||
$mailboxes = [];
|
||||
foreach (($snapshot->mailboxesOf($domain) ?? $current) as $username => $name) {
|
||||
$mailboxes[] = ['username' => (string)$username, 'name' => $name, 'exists' => (int)isset($current[$username])];
|
||||
}
|
||||
return ['domain' => $domain, 'exists' => (int)$this->jobs->isDomain($domain), 'mailboxes' => $mailboxes];
|
||||
}
|
||||
|
||||
/**
|
||||
* State of the backup for admins: settings state, runner heartbeat, repository, newest job of each type, queued and
|
||||
* running jobs, the next scheduled run. [] for everybody else.
|
||||
*/
|
||||
public function status(?int $now = null): array
|
||||
{
|
||||
if (!$this->context->hasAdminRights()) {
|
||||
return [];
|
||||
}
|
||||
$now ??= time();
|
||||
$settings = $this->settings->load();
|
||||
$seen = ($settings->get('state_runner_seen') === '') ? null : (int)$settings->get('state_runner_seen');
|
||||
$snapshots = $this->snapshots->all();
|
||||
$latest = $this->jobs->latestOfEachType();
|
||||
$stats = json_decode($settings->get('state_repo_stats'), true);
|
||||
return [
|
||||
'enabled' => (int)$settings->get('enabled'),
|
||||
'target' => $settings->get('target'),
|
||||
'configured' => (int)$settings->configured(),
|
||||
'password_confirmed' => (int)$settings->get('password_confirmed'),
|
||||
'initialized' => (int)$settings->get('state_initialized'),
|
||||
// the runner cannot decrypt the stored credentials (e.g. a database restored on another server): enter them again
|
||||
'secrets_error' => ($settings->get('state_secrets_error') === '') ? null : $settings->get('state_secrets_error'),
|
||||
// RESTIC_BACKUP_MODE (null until the runner reported it)
|
||||
'mode' => ($settings->get('state_backup_mode') === '') ? null : $settings->get('state_backup_mode'),
|
||||
'runner_seen' => $seen,
|
||||
'runner_age' => ($seen === null) ? null : max(0, $now - $seen),
|
||||
'runner_ok' => (int)($seen !== null && $now - $seen <= self::RUNNER_TIMEOUT),
|
||||
'last_success' => ($settings->get('state_last_success') === '') ? null : (int)$settings->get('state_last_success'),
|
||||
'repo_size' => ($settings->get('state_repo_size') === '') ? null : (int)$settings->get('state_repo_size'),
|
||||
'repo_stats' => is_array($stats) ? $stats : null,
|
||||
'snapshots' => count($snapshots),
|
||||
'latest_snapshot' => empty($snapshots) ? null : $snapshots[0]->time,
|
||||
'next_run' => $this->nextRun($settings, $now),
|
||||
// the longest time between two scheduled backups (seconds): a later last success is overdue
|
||||
'schedule_gap' => $settings->scheduleGap(),
|
||||
'jobs' => array_map(fn(string $type): ?array => isset($latest[$type]) ? $latest[$type]->toArray() : null,
|
||||
array_combine(Job::TYPES, Job::TYPES)),
|
||||
'active' => array_map(fn(Job $job): array => $job->toArray(), $this->jobs->active()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix time of the next scheduled backup, null while the schedule is off. Like the runner: the schedule is armed
|
||||
* (state_schedule_since/state_schedule) when it is switched on or changed, a run is due at the first schedule time
|
||||
* after that; a due run without a scheduled backup since its time is the next one (now).
|
||||
*/
|
||||
private function nextRun(Settings $settings, int $now): ?int
|
||||
{
|
||||
if ($settings->get('enabled') !== '1' || $settings->get('password_confirmed') !== '1' || !$settings->configured()) {
|
||||
return null;
|
||||
}
|
||||
$slots = $settings->scheduleSlots();
|
||||
try {
|
||||
$zone = new DateTimeZone($this->timezone);
|
||||
} catch (\Exception) {
|
||||
$zone = new DateTimeZone('UTC');
|
||||
}
|
||||
$today = (new DateTimeImmutable('@' . $now))->setTimezone($zone);
|
||||
// the last schedule time up to now, and the one after it
|
||||
$due = self::slot($slots, $today, -1);
|
||||
$next = self::slot($slots, $today, 1);
|
||||
$since = $settings->get('state_schedule_since');
|
||||
if ($due === null || $since === '' || $settings->get('state_schedule') !== $settings->get('schedule') || (int)$since > $due) {
|
||||
return $next;
|
||||
}
|
||||
return $this->jobs->scheduledSince($due) ? $next : $due;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest schedule time up to $now ($direction -1) or the first one after it (1) as Unix time, null without
|
||||
* slots ([weekday, hour, minute], sorted)
|
||||
*/
|
||||
private static function slot(array $slots, DateTimeImmutable $now, int $direction): ?int
|
||||
{
|
||||
for ($i = 0; $i <= 7; $i++) {
|
||||
$day = $now->modify(sprintf('%+d day', $direction * $i));
|
||||
foreach (($direction < 0) ? array_reverse($slots) : $slots as [$weekday, $hour, $minute]) {
|
||||
if ($weekday !== (int)$day->format('w')) {
|
||||
continue;
|
||||
}
|
||||
$slot = $day->setTime($hour, $minute)->getTimestamp();
|
||||
if (($direction < 0) ? $slot <= $now->getTimestamp() : $slot > $now->getTimestamp()) {
|
||||
return $slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless the caller may request restores */
|
||||
private function requireRestorer(): void
|
||||
{
|
||||
if ($this->context->isReadOnlyApi() || !($this->context->hasAdminRights() || $this->context->isDomainAdmin())) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
/** Admins every job; domain admins the restores of their domains requested after the domain was created */
|
||||
private function visible(Job $job): bool
|
||||
{
|
||||
if ($this->context->hasAdminRights()) {
|
||||
return true;
|
||||
}
|
||||
if (!$this->context->isDomainAdmin() || $job->type !== 'restore' || $job->domain === '' || !$this->context->canAccessDomain($job->domain)) {
|
||||
return false;
|
||||
}
|
||||
$created = $this->jobs->domainCreated(strtolower($job->domain));
|
||||
return $created !== null && (int)$job->created >= $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value of a job as a domain admin sees it: e-mail addresses outside $domains and repository details become "…"
|
||||
*
|
||||
* @param string[] $domains
|
||||
*/
|
||||
private static function restricted(mixed $value, array $domains): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$restricted = [];
|
||||
foreach ($value as $key => $item) {
|
||||
$restricted[is_string($key) ? (string)self::restricted($key, $domains) : $key] = self::restricted($item, $domains);
|
||||
}
|
||||
return $restricted;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
$value = preg_replace(['~\b(?:s3|sftp|rest|b2|azure|gs|swift|rclone):\S+~i', '~\bhttps?://\S+~i', '~/repository\S*~'], '…', $value);
|
||||
return preg_replace_callback('/[^\s@<>"\'(),;:\[\]]+@([a-z0-9.-]+\.[a-z]{2,})/i',
|
||||
fn(array $match): string => in_array(strtolower($match[1]), $domains, true) ? $match[0] : '…', (string)$value);
|
||||
}
|
||||
|
||||
/** @throws UserError backup_snapshot_not_found (also for snapshots a domain admin may not use) */
|
||||
private function snapshot(mixed $value): Snapshot
|
||||
{
|
||||
$id = (string)Input::text($value);
|
||||
$snapshot = ($id === '') ? null : $this->snapshots->find($id);
|
||||
if ($snapshot === null || (!$this->context->hasAdminRights() && empty(array_filter($snapshot->domainList(),
|
||||
fn(string $domain): bool => $this->mayUse($snapshot, $domain))))) {
|
||||
throw new UserError('backup_snapshot_not_found', [htmlspecialchars($id)]);
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private function visibleSnapshot(mixed $value): ?Snapshot
|
||||
{
|
||||
try {
|
||||
return ($this->context->hasAdminRights() || $this->context->isDomainAdmin()) ? $this->snapshot($value) : null;
|
||||
} catch (UserError) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins every domain of the snapshot; domain admins the domains they administer now, when the snapshot holds this
|
||||
* domain and not a former one of the same name: its recorded creation time is the domain's (snapshots without it:
|
||||
* made after the domain was created)
|
||||
*/
|
||||
private function mayUse(Snapshot $snapshot, string $domain): bool
|
||||
{
|
||||
if ($this->context->hasAdminRights()) {
|
||||
return true;
|
||||
}
|
||||
if (!$this->context->isDomainAdmin()) {
|
||||
return false;
|
||||
}
|
||||
$this->domainState[$domain] ??= [$this->context->canAccessDomain($domain), $this->jobs->domainCreated($domain)];
|
||||
[$access, $created] = $this->domainState[$domain];
|
||||
if (!$access) {
|
||||
return false;
|
||||
}
|
||||
$recorded = $snapshot->createdOf($domain);
|
||||
return $created !== null && (($recorded !== null) ? $recorded === $created : $snapshot->time > $created);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string[]} the domain and its mailboxes in the snapshot (now, for snapshots without content)
|
||||
* @throws UserError
|
||||
*/
|
||||
private function domainScope(Snapshot $snapshot, mixed $value): array
|
||||
{
|
||||
$domain = strtolower((string)Input::text($value));
|
||||
if ($domain !== '' && !$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$exists = $domain !== '' && $this->jobs->isDomain($domain);
|
||||
// a deleted domain is created from the snapshot's export: only when the content lists it
|
||||
if (!$exists && !($this->context->hasAdminRights() && $snapshot->mailboxesOf($domain) !== null)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||
}
|
||||
$this->requireDomain($snapshot, $domain);
|
||||
$listed = $snapshot->mailboxesOf($domain);
|
||||
$mailboxes = ($listed === null) ? $this->jobs->mailboxesOf($domain) : array_map('strval', array_keys($listed));
|
||||
// a deleted domain without mailboxes comes back with its aliases and settings
|
||||
if (empty($mailboxes) && $exists) {
|
||||
throw new UserError('backup_mailboxes_empty');
|
||||
}
|
||||
return [$domain, $mailboxes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string[]} the domain of the mailboxes and the mailboxes
|
||||
* @throws UserError
|
||||
*/
|
||||
private function mailboxScope(Snapshot $snapshot, mixed $value, mixed $domainValue): array
|
||||
{
|
||||
$mailboxes = array_values(array_unique(array_map('strtolower', self::values($value))));
|
||||
if (empty($mailboxes)) {
|
||||
throw new UserError('backup_mailboxes_empty');
|
||||
}
|
||||
if (count($mailboxes) > self::MAX_MAILBOXES) {
|
||||
throw new UserError('backup_entries_exceeded', [self::MAX_MAILBOXES]);
|
||||
}
|
||||
$domain = strtolower((string)Input::text($domainValue));
|
||||
foreach ($mailboxes as $username) {
|
||||
if (str_contains($username, '@') && !$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($username))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
// a deleted mailbox can be restored when the snapshot knows it
|
||||
$mailboxDomain = $this->jobs->mailboxDomain($username) ?? (isset($snapshot->mailboxesOf(self::domainOf($username))[$username]) ? self::domainOf($username) : null);
|
||||
if ($mailboxDomain === null) {
|
||||
throw new UserError('backup_mailbox_not_found', [htmlspecialchars($username)]);
|
||||
}
|
||||
if ($domain !== '' && $mailboxDomain !== $domain) {
|
||||
throw new UserError('backup_mailboxes_one_domain');
|
||||
}
|
||||
$domain = $mailboxDomain;
|
||||
}
|
||||
if (!$this->jobs->isDomain($domain) && !$this->context->hasAdminRights()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$this->requireDomain($snapshot, $domain);
|
||||
return [$domain, $mailboxes];
|
||||
}
|
||||
|
||||
/** @throws UserError the snapshot does not hold the domain, or the caller may not restore into it */
|
||||
private function requireDomain(Snapshot $snapshot, string $domain): void
|
||||
{
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
if ($snapshot->knowsDomains() && !$snapshot->hasDomain($domain)) {
|
||||
throw new UserError('backup_domain_not_in_snapshot', [htmlspecialchars($domain)]);
|
||||
}
|
||||
if (!$this->mayUse($snapshot, $domain)) {
|
||||
throw new UserError('backup_snapshot_not_found', [htmlspecialchars($snapshot->shortId)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] the requested components in their order
|
||||
* @throws UserError
|
||||
*/
|
||||
private function components(mixed $value): array
|
||||
{
|
||||
$requested = array_values(array_unique(self::values($value)));
|
||||
if (empty($requested)) {
|
||||
throw new UserError('backup_components_invalid', ['']);
|
||||
}
|
||||
foreach ($requested as $component) {
|
||||
if (!in_array($component, self::COMPONENTS, true)) {
|
||||
throw new UserError('backup_components_invalid', [htmlspecialchars($component)]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && isset(self::COMPONENT_ACL[$component]) && !$this->context->hasAcl(self::COMPONENT_ACL[$component])) {
|
||||
throw new UserError('backup_component_denied', [$component]);
|
||||
}
|
||||
if (isset(self::REQUIRES[$component]) && !in_array(self::REQUIRES[$component], $requested, true)) {
|
||||
throw new UserError('backup_component_requires', [$component, self::REQUIRES[$component]]);
|
||||
}
|
||||
}
|
||||
return array_values(array_intersect(self::COMPONENTS, $requested));
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
return strtolower(substr((string)strrchr($address, '@'), 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function values(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value));
|
||||
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Backup\Service;
|
||||
|
||||
use Mailcow\Backup\Model\Settings;
|
||||
use Mailcow\Backup\Repository\SettingsRepository;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
|
||||
/**
|
||||
* Backup settings: admins read them (also read-only API keys) and save them. Values are checked strictly (at least as
|
||||
* strictly as the runner), since the runner passes them to restic and ssh. Secrets are write-only and stored sealed
|
||||
* with the public key the runner published (only the runner can decrypt them); an empty secret keeps the stored one,
|
||||
* and without a published key no secret can be saved. A changed target, connection or repository password resets
|
||||
* state_initialized (the runner tests the repository again); a changed SFTP host or port drops the known host key. A
|
||||
* new repository password needs a new confirmation that it is stored outside mailcow. A change of a configured target,
|
||||
* its connection or the notify addresses leaves state_target_changed for the runner, which mails the notify addresses
|
||||
* of before the change.
|
||||
*/
|
||||
class SettingsService
|
||||
{
|
||||
private const FLAGS = ['enabled', 's3_path_style', 'password_confirmed', 'include_index', 'include_fts'];
|
||||
/** Integer settings: name => [min, max] */
|
||||
private const RANGES = [
|
||||
'sftp_port' => [1, 65535],
|
||||
'keep_hourly' => [0, 1000],
|
||||
'keep_daily' => [0, 1000],
|
||||
'keep_weekly' => [0, 1000],
|
||||
'keep_monthly' => [0, 1000],
|
||||
'keep_yearly' => [0, 1000],
|
||||
'prune_weekday' => [0, 6],
|
||||
'check_subset' => [0, 365],
|
||||
'limit_upload' => [0, 100000000],
|
||||
];
|
||||
private const MIN_PASSWORD = 8;
|
||||
private const MAX_LENGTH = 255;
|
||||
private const MAX_NOTIFY = 20;
|
||||
/** Entries of the schedule */
|
||||
private const MAX_SCHEDULE = 24;
|
||||
private const HOSTNAME = '/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i';
|
||||
|
||||
public function __construct(private SettingsRepository $repo, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** The settings for admins, null for everybody else */
|
||||
public function get(): ?Settings
|
||||
{
|
||||
return $this->context->hasAdminRights() ? $this->load() : null;
|
||||
}
|
||||
|
||||
/** The settings without permission check (status, job rules) */
|
||||
public function load(): Settings
|
||||
{
|
||||
return Settings::fromStored($this->repo->load());
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the given settings; missing ones keep their value, unknown and runner-owned ones are ignored.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function save(array $input): Settings
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$current = $this->load();
|
||||
$values = $current->values;
|
||||
foreach ($input as $name => $value) {
|
||||
if (!is_string($name) || !array_key_exists($name, Settings::DEFAULTS) || in_array($name, Settings::RUNNER, true)) {
|
||||
continue;
|
||||
}
|
||||
$value = $this->normalize($name, $value);
|
||||
if (in_array($name, Settings::SECRETS, true)) {
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$value = self::seal($value, $current->get('state_secrets_pubkey'));
|
||||
}
|
||||
$values[$name] = $value;
|
||||
}
|
||||
if ($values['password'] !== $current->get('password')
|
||||
&& !(array_key_exists('password_confirmed', $input) && $values['password_confirmed'] === '1')) {
|
||||
$values['password_confirmed'] = '0';
|
||||
}
|
||||
foreach (array_merge(['target', 'password'], Settings::CONNECTION[$values['target']]) as $name) {
|
||||
if ($values[$name] !== $current->get($name)) {
|
||||
$values['state_initialized'] = '0';
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($values['sftp_host'] !== $current->get('sftp_host') || $values['sftp_port'] !== $current->get('sftp_port')) {
|
||||
$values['sftp_host_key'] = '';
|
||||
}
|
||||
$settings = new Settings($values);
|
||||
if ($values['enabled'] === '1' && !$settings->configured()) {
|
||||
throw new UserError('backup_not_configured');
|
||||
}
|
||||
// switched on without a time
|
||||
if ($values['enabled'] === '1' && empty($settings->scheduleSlots())) {
|
||||
throw self::invalid('schedule');
|
||||
}
|
||||
$values['state_target_changed'] = $this->targetChange($current, $values);
|
||||
$settings = new Settings($values);
|
||||
$changed = array_diff_assoc($values, $current->values);
|
||||
if (!empty($changed)) {
|
||||
$this->repo->save($changed);
|
||||
}
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* state_target_changed after a save: when the target, its connection or the notify addresses change, what the target
|
||||
* was, whom to tell (the notify addresses before the change) and what changed ("target", "notify_email"). An earlier
|
||||
* change the runner has not mailed yet keeps its state and addresses and only gets the new kinds of change, so new
|
||||
* addresses followed by a new target still go to the original addresses. Nothing is recorded for a target that was
|
||||
* not configured or without notify addresses.
|
||||
*/
|
||||
private function targetChange(Settings $current, array $values): string
|
||||
{
|
||||
$pending = json_decode($current->get('state_target_changed'), true);
|
||||
if (!$current->configured() && !is_array($pending)) {
|
||||
return $current->get('state_target_changed');
|
||||
}
|
||||
$old = $current->get('target');
|
||||
$watched = array_unique(array_merge(['target'], Settings::CONNECTION[$old] ?? [], Settings::CONNECTION[$values['target']] ?? []));
|
||||
$changed = [];
|
||||
if (!empty(array_filter($watched, fn(string $name): bool => $values[$name] !== $current->get($name)))) {
|
||||
$changed[] = 'target';
|
||||
}
|
||||
if ($values['notify_email'] !== $current->get('notify_email')) {
|
||||
$changed[] = 'notify_email';
|
||||
}
|
||||
if (is_array($pending)) {
|
||||
$pending['changed'] = array_values(array_unique(array_merge((array)($pending['changed'] ?? ['target']), $changed)));
|
||||
return (string)json_encode($pending, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
$addresses = array_values(array_filter(explode(',', $current->get('notify_email')), 'strlen'));
|
||||
if (empty($changed) || empty($addresses)) {
|
||||
return '';
|
||||
}
|
||||
$summary = ['target' => $old];
|
||||
foreach (array_diff(Settings::CONNECTION[$old] ?? [], Settings::SECRETS) as $name) {
|
||||
$summary[$name] = $current->get($name);
|
||||
}
|
||||
return (string)json_encode(['old_target' => $summary, 'old_addresses' => $addresses, 'changed' => $changed,
|
||||
'changed_by' => (string)$this->context->username, 'time' => time()], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* A secret sealed for the runner: sealed:v1:<base64 of sodium_crypto_box_seal>
|
||||
*
|
||||
* @throws UserError backup_secrets_key_missing while the runner has not published its key
|
||||
*/
|
||||
private static function seal(string $secret, string $publicKey): string
|
||||
{
|
||||
$key = base64_decode($publicKey, true);
|
||||
if ($key === false || strlen($key) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
|
||||
throw new UserError('backup_secrets_key_missing');
|
||||
}
|
||||
return Settings::SEALED . base64_encode(sodium_crypto_box_seal($secret, $key));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value to store, '' for an empty optional value.
|
||||
*
|
||||
* @throws UserError backup_setting_invalid, backup_password_too_short
|
||||
*/
|
||||
private function normalize(string $name, mixed $value): string
|
||||
{
|
||||
if (in_array($name, self::FLAGS, true)) {
|
||||
return self::flag($value) ?? throw self::invalid($name);
|
||||
}
|
||||
if ($name === 'notify_email' && is_string($value)) {
|
||||
// one address per line is fine as well
|
||||
$value = preg_replace('/[\r\n\t]+/', ',', $value);
|
||||
}
|
||||
if ($name === 'schedule') {
|
||||
return self::schedule($value) ?? throw self::invalid($name);
|
||||
}
|
||||
// secrets as typed: spaces at the ends are part of a password
|
||||
$text = (in_array($name, Settings::SECRETS, true) && is_string($value)) ? $value : Input::text($value);
|
||||
if ($text === null || mb_strlen($text) > (($name === 'notify_email') ? self::MAX_LENGTH * self::MAX_NOTIFY : self::MAX_LENGTH) || preg_match('/[\x00-\x1f\x7f]/', $text) || !mb_check_encoding($text, 'UTF-8')) {
|
||||
throw self::invalid($name);
|
||||
}
|
||||
// no scheduled prune (append-only or object-lock targets); a prune job still works
|
||||
if ($name === 'prune_weekday' && $text === '') {
|
||||
return '';
|
||||
}
|
||||
if (isset(self::RANGES[$name])) {
|
||||
$int = Input::intInRange($text, ...self::RANGES[$name]);
|
||||
return ($int === null) ? throw self::invalid($name) : (string)$int;
|
||||
}
|
||||
$valid = match ($name) {
|
||||
'target' => in_array($text, Settings::TARGETS, true),
|
||||
// like the runner: scheme, host and port only
|
||||
's3_endpoint' => self::url($text) && in_array(parse_url($text, PHP_URL_PATH) ?? '', ['', '/'], true),
|
||||
'rest_url' => $text === '' || self::url($text),
|
||||
's3_bucket' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{1,62}$/i', $text),
|
||||
's3_path' => $text === '' || self::path(trim($text, '/'), false),
|
||||
's3_region' => $text === '' || preg_match('/^[a-z0-9-]{1,64}$/i', $text),
|
||||
's3_access_key' => preg_match('/^[\x21-\x7e]*$/', $text),
|
||||
's3_secret_key', 'rest_password' => true,
|
||||
'sftp_host' => $text === '' || preg_match(self::HOSTNAME, $text) || filter_var($text, FILTER_VALIDATE_IP),
|
||||
// the runner's character set (ssh -l)
|
||||
'sftp_user' => $text === '' || preg_match('/^[a-z0-9_][a-z0-9._-]{0,63}$/i', $text),
|
||||
'sftp_path' => $text === '' || self::path($text, true),
|
||||
'rest_user' => $text === '' || preg_match('/^[a-z0-9][a-z0-9._-]{0,63}$/i', $text),
|
||||
// the runner's character set, below the repository folder
|
||||
'local_path' => preg_match('~^[a-z0-9._-]+(/[a-z0-9._-]+)*$~i', $text) && self::path($text, false),
|
||||
'password' => true,
|
||||
'max_unused' => preg_match('/^(\d{1,3})\s*%?$/', $text, $percent) && (int)$percent[1] <= 100,
|
||||
'notify_email' => true,
|
||||
default => false,
|
||||
};
|
||||
if (!$valid) {
|
||||
throw self::invalid($name);
|
||||
}
|
||||
return match ($name) {
|
||||
's3_path' => trim($text, '/'),
|
||||
's3_endpoint' => rtrim($text, '/'),
|
||||
'sftp_path' => rtrim($text, '/'),
|
||||
'password' => self::password($text),
|
||||
'max_unused' => (int)$text . '%',
|
||||
'notify_email' => self::emails($text),
|
||||
default => $text,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule as stored: a list (or its JSON) of {days: weekdays 0 (Sunday) to 6, time: "H:MM"}, up to MAX_SCHEDULE
|
||||
* entries, each with at least one weekday; days sorted, times as HH:MM, repeated entries dropped. Null when invalid.
|
||||
*/
|
||||
private static function schedule(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$value = json_decode($value, true);
|
||||
}
|
||||
if (!is_array($value) || !array_is_list($value) || count($value) > self::MAX_SCHEDULE) {
|
||||
return null;
|
||||
}
|
||||
$entries = [];
|
||||
foreach ($value as $entry) {
|
||||
$days = $entry['days'] ?? null;
|
||||
$time = is_array($entry) && is_string($entry['time'] ?? null) ? $entry['time'] : '';
|
||||
if (!is_array($days) || empty($days) || !preg_match('/^([01]?\d|2[0-3]):([0-5]\d)$/', $time, $match)) {
|
||||
return null;
|
||||
}
|
||||
foreach ($days as $day) {
|
||||
if (Input::intInRange($day, 0, 6) === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$days = array_values(array_unique(array_map('intval', $days)));
|
||||
sort($days);
|
||||
$entries[] = ['days' => $days, 'time' => sprintf('%02d:%02d', $match[1], $match[2])];
|
||||
}
|
||||
return (string)json_encode(array_values(array_unique($entries, SORT_REGULAR)));
|
||||
}
|
||||
|
||||
/** http(s) URL without credentials, query or fragment (rest_user/rest_password hold the credentials) */
|
||||
private static function url(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
return filter_var($url, FILTER_VALIDATE_URL) !== false && is_array($parts) && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
||||
&& !isset($parts['user']) && !isset($parts['pass']) && !isset($parts['query']) && !isset($parts['fragment'])
|
||||
&& (preg_match(self::HOSTNAME, (string)($parts['host'] ?? '')) || filter_var(trim((string)($parts['host'] ?? ''), '[]'), FILTER_VALIDATE_IP));
|
||||
}
|
||||
|
||||
/** A path of plain names separated by /, without . and .. parts; a leading / only where $absolute is allowed */
|
||||
private static function path(string $path, bool $absolute): bool
|
||||
{
|
||||
if ($path === '' || (str_starts_with($path, '/') && !$absolute)) {
|
||||
return false;
|
||||
}
|
||||
foreach (explode('/', trim($path, '/')) as $part) {
|
||||
if ($part === '.' || $part === '..' || !preg_match('/^[a-z0-9_~+@][a-z0-9._~+@-]*$/i', $part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @throws UserError backup_password_too_short ('' keeps the stored password) */
|
||||
private static function password(string $password): string
|
||||
{
|
||||
if ($password !== '' && mb_strlen($password) < self::MIN_PASSWORD) {
|
||||
throw new UserError('backup_password_too_short', [self::MIN_PASSWORD]);
|
||||
}
|
||||
return $password;
|
||||
}
|
||||
|
||||
/** @throws UserError for an invalid address or too many */
|
||||
private static function emails(string $text): string
|
||||
{
|
||||
$emails = array_values(array_unique(array_filter(preg_split('/[\s,;]+/', $text), 'strlen')));
|
||||
if (count($emails) > self::MAX_NOTIFY) {
|
||||
throw self::invalid('notify_email');
|
||||
}
|
||||
foreach ($emails as $email) {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw self::invalid('notify_email');
|
||||
}
|
||||
}
|
||||
return implode(',', $emails);
|
||||
}
|
||||
|
||||
/** Checkbox or API flag as '0'/'1'; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked; null = invalid */
|
||||
private static function flag(mixed $value): ?string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = end($value);
|
||||
}
|
||||
if (!is_scalar($value) && $value !== null) {
|
||||
return null;
|
||||
}
|
||||
$flag = filter_var($value ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
return ($flag === null) ? null : (string)(int)$flag;
|
||||
}
|
||||
|
||||
private static function invalid(string $name): UserError
|
||||
{
|
||||
return new UserError('backup_setting_invalid', [$name]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
// E-Mail > Restore of admins and domain admins (admin/restore.php, domainadmin/restore.php set $role after protect_route):
|
||||
// restores from the snapshots of the backup, also of deleted mailboxes (and domains for admins)
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
$js_minifier->add('/web/js/site/backup.js');
|
||||
|
||||
$template = 'restore.twig';
|
||||
$template_data = [
|
||||
'acl' => $_SESSION['acl'],
|
||||
'role' => $role,
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'backup_configured' => \Mailcow\Backup\BackupModule::fromGlobals()->settings->load()->configured(),
|
||||
'backup_timezone' => getenv('TZ') ?: 'UTC',
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
Reference in new issue
Block a user