fix: harden backend synchronization

This commit is contained in:
Marek Miklewicz
2026-06-02 21:06:25 +02:00
parent 4b531a4c24
commit 64b62a5793
20 changed files with 254 additions and 16 deletions
+30 -4
View File
@@ -50,6 +50,23 @@ function attach_rule_scope(AppContext $ctx, array $rule): array
return $rule;
}
/** @param array<string,mixed> $rule */
function enforce_rule_backend_constraints(AppContext $ctx, ?string $currentId, array $rule): void
{
if ($ctx->settings->backendMode() !== 'da' || empty($rule['enabled'])) {
return;
}
foreach ($ctx->rules->all($ctx->daUser->username()) as $existing) {
if (empty($existing['enabled'])) {
continue;
}
if ($currentId !== null && (string)($existing['id'] ?? '') === $currentId) {
continue;
}
throw new RuntimeException($ctx->t('DirectAdmin backend supports only one enabled global rule.'));
}
}
function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value): string
{
$mode = $ctx->settings->scheduleMode();
@@ -79,15 +96,24 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st
'</select>';
}
return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar" data-calendar-picker data-schedule-mode="' . AppContext::e($mode) . '" data-calendar-name="' . AppContext::e($name) . '" data-visible-month="' . AppContext::e($visibleMonth) . '" data-selected-date="' . AppContext::e($selectedDate) . '"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn" data-calendar-prev>&lsaquo;</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>&rsaquo;</button></div>' .
'<table class="br-calendar-grid"><thead><tr><th>Pn</th><th>Wt</th><th>Śr</th><th>Cz</th><th>Pt</th><th>Sb</th><th>Nd</th></tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' .
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">Wybrano: ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
'<table class="br-calendar-grid"><thead><tr>' . render_weekday_headers($ctx) . '</tr></thead><tbody data-calendar-days>' . $days . '</tbody></table>' .
'<div class="br-date-label" id="' . AppContext::e($name . '_label') . '">' . AppContext::e($ctx->t('Selected')) . ': ' . AppContext::e($selectedDate) . '</div><input type="hidden" id="' . AppContext::e($name . '_date') . '" name="' . AppContext::e($name . '_date') . '" value="' . AppContext::e($selectedDate) . '"></div>' .
'<div class="br-restore-calendar"><h4>' . AppContext::e($ctx->t('Time')) . '</h4>' . $timeControl . '</div></div>' .
'<input type="hidden" id="' . AppContext::e($name) . '" name="' . AppContext::e($name) . '" value="' . AppContext::e($value) . '" data-canonical-value="' . AppContext::e($name) . '"></div>';
}
function render_preview_box(array $rule): string
function render_preview_box(AppContext $ctx, array $rule): string
{
return '<div class="preview-box"><div><strong>Temat:</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
return '<div class="preview-box"><div><strong>' . AppContext::e($ctx->t('Subject')) . ':</strong> ' . AppContext::e((string)$rule['subject']) . '</div><br><div>' . nl2br(AppContext::e((string)$rule['body'])) . '</div></div>';
}
function render_weekday_headers(AppContext $ctx): string
{
$html = '';
foreach (['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as $day) {
$html .= '<th>' . AppContext::e($ctx->t($day)) . '</th>';
}
return $html;
}
function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string
+8 -2
View File
@@ -8,8 +8,14 @@ return static function (AppContext $ctx): void {
$ctx->requireCsrf('create');
$input = normalize_rule_post($_POST);
$rule = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
$ctx->rules->create($ctx->daUser->username(), $rule);
$ctx->syncActiveBackend();
enforce_rule_backend_constraints($ctx, null, $rule);
$created = $ctx->rules->create($ctx->daUser->username(), $rule);
try {
$ctx->syncActiveBackend();
} catch (Throwable $syncError) {
$ctx->rules->delete($ctx->daUser->username(), (string)$created['id']);
throw $syncError;
}
Http::redirect($ctx->url('index.html', ['status' => 'created']));
} catch (Throwable $e) {
$errors[] = $e->getMessage();
+1 -1
View File
@@ -20,7 +20,7 @@ return static function (AppContext $ctx): void {
return;
}
}
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($ctx, $rule) . '</div>' .
'<div class="panel"><h3>' . AppContext::e($ctx->t('Confirm deletion')) . '</h3><div class="alert alert-err">' . AppContext::e($ctx->t('Deleting this rule will disable the automatic reply created by this rule for covered mailboxes.')) . '</div>' .
'<form method="post" action="' . AppContext::e($ctx->url('delete.html')) . '">' . $ctx->csrfField('delete:' . $id) . '<input type="hidden" name="id" value="' . AppContext::e($id) . '">' .
'<div class="actions"><button class="danger-fill" type="submit">' . AppContext::e($ctx->t('Delete autoresponder')) . '</button><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Do not delete')) . '</a></div></form></div></div>';
+1 -1
View File
@@ -8,7 +8,7 @@ return static function (AppContext $ctx): void {
$ctx->render('Preview autoresponder', '<div class="alert alert-err">' . AppContext::e($ctx->t('Rule not found')) . '</div>');
return;
}
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($rule) . '</div>' .
$body = '<div class="grid grid-2"><div class="panel"><h3>' . AppContext::e($ctx->t('Preview autoresponder')) . '</h3>' . render_preview_box($ctx, $rule) . '</div>' .
'<div class="panel"><h3>' . AppContext::e($ctx->t('Rule details')) . '</h3><div class="summary-card"><div class="kv"><strong>' . AppContext::e($ctx->t('Status')) . '</strong><span>' . AppContext::e($ctx->t(!empty($rule['enabled']) ? 'Enabled' : 'Disabled')) . '</span></div>' .
'<div class="kv"><strong>' . AppContext::e($ctx->t('Sending period')) . '</strong><span>' . AppContext::e(date('Y-m-d H:i', (int)$rule['start_ts']) . ' - ' . date('Y-m-d H:i', (int)$rule['end_ts'])) . '</span></div></div>' .
'<div class="actions"><a class="btn" href="' . AppContext::e($ctx->url('update.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Edit')) . '</a><a class="btn danger" href="' . AppContext::e($ctx->url('delete.html', ['id' => $id])) . '">' . AppContext::e($ctx->t('Delete')) . '</a><a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">' . AppContext::e($ctx->t('Back')) . '</a></div></div></div>';
+3 -1
View File
@@ -9,7 +9,9 @@ return static function (AppContext $ctx): void {
if ($rule === null) {
throw new RuntimeException($ctx->t('Rule not found'));
}
$ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]);
$next = ['enabled' => empty($rule['enabled'])];
enforce_rule_backend_constraints($ctx, $id, $next);
$ctx->rules->update($ctx->daUser->username(), $id, $next);
$ctx->syncActiveBackend();
Http::redirect($ctx->url('index.html', ['status' => 'toggled']));
};
+1
View File
@@ -14,6 +14,7 @@ return static function (AppContext $ctx): void {
$ctx->requireCsrf('update:' . $id);
$input = normalize_rule_post($_POST);
$patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
enforce_rule_backend_constraints($ctx, $id, $patch);
$ctx->rules->update($ctx->daUser->username(), $id, $patch);
$ctx->syncActiveBackend();
Http::redirect($ctx->url('index.html', ['status' => 'updated']));