feat: complete production backend integration

This commit is contained in:
Marek Miklewicz
2026-06-02 20:51:57 +02:00
parent d3f2fd69db
commit 4b531a4c24
20 changed files with 853 additions and 40 deletions
+2
View File
@@ -20,6 +20,8 @@ foreach ([
'CsrfGuard.php',
'RuleRepository.php',
'DirectAdminSyncRepository.php',
'DirectAdminVacationApi.php',
'DirectAdminVacationSyncService.php',
'RuleValidator.php',
'MailboxDirectory.php',
'AuditLog.php',
+56 -15
View File
@@ -41,10 +41,22 @@ function normalize_rule_post(array $post): array
return $post;
}
/** @param array<string,mixed> $rule @return array<string,mixed> */
function attach_rule_scope(AppContext $ctx, array $rule): array
{
if (empty($rule['dynamic_scope'])) {
$rule['mailbox_snapshot'] = (new MailboxDirectory())->mailboxesForUser($ctx->daUser->username());
}
return $rule;
}
function render_calendar_picker(AppContext $ctx, string $name, string $title, string $value): string
{
$mode = $ctx->settings->scheduleMode();
$selectedDate = substr($value, 0, 10);
if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $selectedDate)) {
$selectedDate = date('Y-m-d');
}
$selectedTime = '09:00';
$selectedPeriod = 'morning';
if ($mode === 'exact_hour' && preg_match('/T([0-9]{2}:[0-9]{2})$/', $value, $m)) {
@@ -52,33 +64,62 @@ function render_calendar_picker(AppContext $ctx, string $name, string $title, st
} elseif ($mode === 'directadmin_period' && preg_match('/\|(morning|afternoon|evening)$/', $value, $m)) {
$selectedPeriod = $m[1];
}
$days = '';
for ($week = 0, $day = 1; $week < 4; $week++) {
$days .= '<tr>';
for ($i = 0; $i < 7; $i++, $day++) {
$date = sprintf('2026-06-%02d', $day);
$class = $date === $selectedDate ? ' is-selected' : '';
$days .= '<td><button type="button" class="br-cal-day' . $class . '" data-date-target="' . AppContext::e($name . '_date') . '" data-date-label="' . AppContext::e($name . '_label') . '" data-date-value="' . AppContext::e($date) . '">' . $day . '</button></td>';
}
$days .= '</tr>';
}
$selectedDt = DateTimeImmutable::createFromFormat('!Y-m-d', $selectedDate) ?: new DateTimeImmutable('today');
$monthStart = $selectedDt->modify('first day of this month');
$days = render_calendar_days($name, $selectedDate, $monthStart);
$visibleMonth = $monthStart->format('Y-m');
$monthLabel = calendar_month_label($ctx, $monthStart);
if ($mode === 'exact_hour') {
$timeControl = '<label>' . AppContext::e($ctx->t('Time')) . '</label><input type="time" name="' . AppContext::e($name . '_time') . '" value="' . AppContext::e($selectedTime) . '" step="60">';
$timeControl = '<label>' . AppContext::e($ctx->t('Time')) . '</label><input type="time" name="' . AppContext::e($name . '_time') . '" value="' . AppContext::e($selectedTime) . '" step="60" data-time-for="' . AppContext::e($name) . '">';
} else {
$timeControl = '<label>' . AppContext::e($ctx->t('Time of day')) . '</label><select name="' . AppContext::e($name . '_period') . '">' .
$timeControl = '<label>' . AppContext::e($ctx->t('Time of day')) . '</label><select name="' . AppContext::e($name . '_period') . '" data-period-for="' . AppContext::e($name) . '">' .
'<option value="morning"' . ($selectedPeriod === 'morning' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Morning')) . '</option>' .
'<option value="afternoon"' . ($selectedPeriod === 'afternoon' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Afternoon')) . '</option>' .
'<option value="evening"' . ($selectedPeriod === 'evening' ? ' selected' : '') . '>' . AppContext::e($ctx->t('Evening')) . '</option>' .
'</select>';
}
return '<div class="panel"><h3>' . AppContext::e($title) . '</h3><div class="br-restore-layout"><div class="br-restore-calendar"><h4>' . AppContext::e($title) . '</h4><div class="br-month-nav"><button type="button" class="br-month-btn">&lsaquo;</button><span class="br-month-label">Czerwiec 2026</span><button type="button" class="br-month-btn">&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>' . $days . '</tbody></table>' .
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>' .
'<div class="br-restore-calendar"><h4>' . AppContext::e($ctx->t('Time')) . '</h4>' . $timeControl . '</div></div>' .
'<input type="hidden" name="' . AppContext::e($name) . '" value="' . AppContext::e($value) . '"></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
{
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>';
}
function render_calendar_days(string $name, string $selectedDate, DateTimeImmutable $monthStart): string
{
$offset = ((int)$monthStart->format('N')) - 1;
$daysInMonth = (int)$monthStart->format('t');
$html = '';
$day = 1;
for ($week = 0; $week < 6; $week++) {
$html .= '<tr>';
for ($i = 0; $i < 7; $i++) {
if (($week === 0 && $i < $offset) || $day > $daysInMonth) {
$html .= '<td class="br-cal-empty"></td>';
continue;
}
$date = $monthStart->setDate((int)$monthStart->format('Y'), (int)$monthStart->format('m'), $day)->format('Y-m-d');
$class = $date === $selectedDate ? ' is-selected' : '';
$html .= '<td><button type="button" class="br-cal-day' . $class . '" data-date-target="' . AppContext::e($name . '_date') . '" data-date-label="' . AppContext::e($name . '_label') . '" data-date-value="' . AppContext::e($date) . '">' . $day . '</button></td>';
$day++;
}
$html .= '</tr>';
if ($day > $daysInMonth) {
break;
}
}
return $html;
}
function calendar_month_label(AppContext $ctx, DateTimeImmutable $monthStart): string
{
$pl = ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'];
$en = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
$months = $ctx->lang->code() === 'pl' ? $pl : $en;
return $months[((int)$monthStart->format('n')) - 1] . ' ' . $monthStart->format('Y');
}
+2 -1
View File
@@ -7,8 +7,9 @@ return static function (AppContext $ctx): void {
try {
$ctx->requireCsrf('create');
$input = normalize_rule_post($_POST);
$rule = RuleValidator::validate($input, $ctx->settings);
$rule = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
$ctx->rules->create($ctx->daUser->username(), $rule);
$ctx->syncActiveBackend();
Http::redirect($ctx->url('index.html', ['status' => 'created']));
} catch (Throwable $e) {
$errors[] = $e->getMessage();
+1
View File
@@ -12,6 +12,7 @@ return static function (AppContext $ctx): void {
try {
$ctx->requireCsrf('delete:' . $id);
$ctx->rules->delete($ctx->daUser->username(), $id);
$ctx->syncActiveBackend();
$ctx->audit->record('delete', $ctx->daUser->username(), ['rule_id' => $id, 'subject' => (string)$rule['subject']]);
Http::redirect($ctx->url('index.html', ['status' => 'deleted']));
} catch (Throwable $e) {
+1
View File
@@ -10,5 +10,6 @@ return static function (AppContext $ctx): void {
throw new RuntimeException($ctx->t('Rule not found'));
}
$ctx->rules->update($ctx->daUser->username(), $id, ['enabled' => empty($rule['enabled'])]);
$ctx->syncActiveBackend();
Http::redirect($ctx->url('index.html', ['status' => 'toggled']));
};
+2 -1
View File
@@ -13,8 +13,9 @@ return static function (AppContext $ctx): void {
try {
$ctx->requireCsrf('update:' . $id);
$input = normalize_rule_post($_POST);
$patch = RuleValidator::validate($input, $ctx->settings);
$patch = attach_rule_scope($ctx, RuleValidator::validate($input, $ctx->settings));
$ctx->rules->update($ctx->daUser->username(), $id, $patch);
$ctx->syncActiveBackend();
Http::redirect($ctx->url('index.html', ['status' => 'updated']));
} catch (Throwable $e) {
$errors[] = $e->getMessage();
+87 -1
View File
@@ -70,6 +70,20 @@ final class AppContext
}
}
public function syncActiveBackend(): void
{
if ($this->settings->backendMode() !== 'da') {
return;
}
$service = new DirectAdminVacationSyncService(
$this->rules,
new DirectAdminSyncRepository(),
new MailboxDirectory(),
new DirectAdminVacationApi()
);
$service->syncUser($this->daUser->username());
}
public function render(string $title, string $body, array $ok = [], array $errors = []): void
{
header('Content-Type: text/html; charset=UTF-8');
@@ -109,17 +123,89 @@ final class AppContext
private function scripts(): string
{
$selectedLabel = json_encode($this->t('Selected'), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
$monthNames = json_encode($this->lang->code() === 'pl'
? ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień']
: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
return <<<JS
(function () {
var selectedLabel = {$selectedLabel};
document.querySelectorAll('[data-date-value]').forEach(function (btn) {
var monthNames = {$monthNames};
function pad(value) { return value < 10 ? '0' + value : String(value); }
function updateCanonical(name) {
var date = document.getElementById(name + '_date');
var canonical = document.getElementById(name);
if (!date || !canonical) { return; }
var time = document.querySelector('[data-time-for="' + name + '"]');
var period = document.querySelector('[data-period-for="' + name + '"]');
if (time) {
canonical.value = date.value + 'T' + (time.value || '09:00');
} else if (period) {
canonical.value = date.value + '|' + (period.value || 'morning');
}
}
function bindDateButton(btn) {
btn.addEventListener('click', function () {
var target = document.getElementById(btn.getAttribute('data-date-target'));
var label = document.getElementById(btn.getAttribute('data-date-label'));
if (target) { target.value = btn.getAttribute('data-date-value') || ''; }
if (label) { label.textContent = selectedLabel + ': ' + (btn.getAttribute('data-date-value') || ''); }
var calendar = btn.closest('[data-calendar-picker]');
if (calendar) { calendar.setAttribute('data-selected-date', btn.getAttribute('data-date-value') || ''); }
btn.closest('.br-restore-calendar').querySelectorAll('.br-cal-day').forEach(function (d) { d.classList.remove('is-selected'); });
btn.classList.add('is-selected');
if (calendar) { updateCanonical(calendar.getAttribute('data-calendar-name')); }
});
}
function renderMonth(calendar, year, month) {
var selected = calendar.getAttribute('data-selected-date') || '';
var tbody = calendar.querySelector('[data-calendar-days]');
var label = calendar.querySelector('[data-calendar-month-label]');
if (!tbody || !label) { return; }
var first = new Date(Date.UTC(year, month - 1, 1));
var offset = (first.getUTCDay() + 6) % 7;
var days = new Date(Date.UTC(year, month, 0)).getUTCDate();
var day = 1;
var html = '';
for (var week = 0; week < 6; week++) {
html += '<tr>';
for (var i = 0; i < 7; i++) {
if ((week === 0 && i < offset) || day > days) {
html += '<td class="br-cal-empty"></td>';
continue;
}
var value = year + '-' + pad(month) + '-' + pad(day);
var cls = value === selected ? ' is-selected' : '';
html += '<td><button type="button" class="br-cal-day' + cls + '" data-date-target="' + calendar.getAttribute('data-calendar-name') + '_date" data-date-label="' + calendar.getAttribute('data-calendar-name') + '_label" data-date-value="' + value + '">' + day + '</button></td>';
day++;
}
html += '</tr>';
if (day > days) { break; }
}
tbody.innerHTML = html;
label.textContent = monthNames[month - 1] + ' ' + year;
calendar.setAttribute('data-visible-month', year + '-' + pad(month));
tbody.querySelectorAll('[data-date-value]').forEach(bindDateButton);
}
document.querySelectorAll('[data-date-value]').forEach(function (btn) {
bindDateButton(btn);
});
document.querySelectorAll('[data-calendar-picker]').forEach(function (calendar) {
var name = calendar.getAttribute('data-calendar-name');
calendar.querySelectorAll('[data-calendar-prev],[data-calendar-next]').forEach(function (btn) {
btn.addEventListener('click', function () {
var parts = (calendar.getAttribute('data-visible-month') || '').split('-');
var year = parseInt(parts[0], 10);
var month = parseInt(parts[1], 10);
if (!year || !month) { return; }
month += btn.hasAttribute('data-calendar-next') ? 1 : -1;
if (month < 1) { month = 12; year--; }
if (month > 12) { month = 1; year++; }
renderMonth(calendar, year, month);
});
});
document.querySelectorAll('[data-time-for="' + name + '"],[data-period-for="' + name + '"]').forEach(function (control) {
control.addEventListener('change', function () { updateCanonical(name); });
control.addEventListener('input', function () { updateCanonical(name); });
});
});
})();
+6
View File
@@ -22,6 +22,12 @@ final class DirectAdminSyncRepository
return array_values($data['rules'][$ruleId] ?? []);
}
/** @return array<string,array<int,array<string,mixed>>> */
public function allRuleEntries(string $username): array
{
return $this->read($username)['rules'];
}
/** @return array<int,array<string,mixed>> */
public function deleteRuleEntries(string $username, string $ruleId): array
{
+90 -6
View File
@@ -1,14 +1,18 @@
<?php
declare(strict_types=1);
final class DirectAdminVacationApi
class DirectAdminVacationApi
{
public function __construct(private string $directadminBinary = '/usr/local/directadmin/directadmin')
/** @var callable|null */
private $request;
public function __construct(private string $daBinary = '/usr/local/bin/da', ?callable $request = null)
{
$this->request = $request;
}
/** @param array<string,mixed> $rule @return array<string,string> */
public function buildSavePayload(string $email, array $rule): array
public function buildSavePayload(string $email, array $rule, bool $exists = true): array
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid mailbox email');
@@ -16,20 +20,46 @@ final class DirectAdminVacationApi
[$local, $domain] = explode('@', strtolower($email), 2);
[$startDate, $startTime] = $this->splitPeriod((string)($rule['start_value'] ?? ''));
[$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? ''));
[$startYear, $startMonth, $startDay] = explode('-', $startDate);
[$endYear, $endMonth, $endDay] = explode('-', $endDate);
return [
'action' => 'modify',
'action' => $exists ? 'modify' : 'create',
'domain' => $domain,
'user' => $local,
'email' => $email,
'text' => (string)($rule['body'] ?? ''),
'subject' => (string)($rule['subject'] ?? ''),
'startdate' => $startDate,
'starttime' => $startTime,
'enddate' => $endDate,
'startyear' => $startYear,
'startmonth' => $startMonth,
'startday' => $startDay,
'endtime' => $endTime,
'endyear' => $endYear,
'endmonth' => $endMonth,
'endday' => $endDay,
'create' => $exists ? '' : 'Create',
];
}
/** @param array<string,mixed> $rule */
public function saveVacation(string $owner, string $email, array $rule, bool $exists): void
{
$this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', $this->buildSavePayload($email, $rule, $exists));
}
public function deleteVacation(string $owner, string $email): void
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid mailbox email');
}
[$local, $domain] = explode('@', strtolower($email), 2);
$this->requestAsUser($owner, 'CMD_API_EMAIL_VACATION', [
'action' => 'delete',
'domain' => $domain,
'select0' => $local,
]);
}
/** @return array{0:string,1:string} */
private function splitPeriod(string $value): array
{
@@ -38,4 +68,58 @@ final class DirectAdminVacationApi
}
return [$m[1], $m[2]];
}
/** @param array<string,string> $payload */
private function requestAsUser(string $owner, string $endpoint, array $payload): void
{
if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) {
throw new InvalidArgumentException('Invalid DirectAdmin owner');
}
if ($this->request !== null) {
$result = call_user_func($this->request, $owner, $endpoint, $payload);
if ($result === false) {
throw new RuntimeException('DirectAdmin API request failed');
}
return;
}
$baseUrl = $this->apiUrl($owner);
$url = rtrim($baseUrl, '/') . '/' . $endpoint;
$args = ['curl', '-fsS', '-X', 'POST'];
foreach ($payload as $key => $value) {
$args[] = '--data-urlencode';
$args[] = $key . '=' . $value;
}
$args[] = $url;
$this->run($args, '');
}
private function apiUrl(string $owner): string
{
$output = $this->run([$this->daBinary, 'api-url', '--user=' . $owner], '');
$url = trim($output);
if ($url === '' || !preg_match('#^https?://#', $url)) {
throw new RuntimeException('Cannot determine DirectAdmin API URL for user');
}
return $url;
}
/** @param string[] $args */
private function run(array $args, string $stdin): string
{
$process = proc_open($args, [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes);
if (!is_resource($process)) {
throw new RuntimeException('Cannot start DirectAdmin API command');
}
fwrite($pipes[0], $stdin);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
if ($code !== 0) {
throw new RuntimeException('DirectAdmin API command failed: ' . trim($stderr));
}
return $stdout;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
final class DirectAdminVacationSyncService
{
public function __construct(
private RuleRepository $rules,
private DirectAdminSyncRepository $sync,
private MailboxDirectory $mailboxes,
private DirectAdminVacationApi $api
) {
}
public function syncUser(string $username): void
{
$rules = $this->rulesById($username);
foreach ($this->sync->allRuleEntries($username) as $ruleId => $entries) {
if (!isset($rules[$ruleId]) || empty($rules[$ruleId]['enabled'])) {
$this->deleteEntries($username, $ruleId, $entries);
}
}
foreach ($rules as $ruleId => $rule) {
if (empty($rule['enabled'])) {
continue;
}
$this->syncRule($username, $ruleId, $rule);
}
}
/** @return array<string,array<string,mixed>> */
private function rulesById(string $username): array
{
$out = [];
foreach ($this->rules->all($username) as $rule) {
$id = (string)($rule['id'] ?? '');
if ($id !== '') {
$out[$id] = $rule;
}
}
return $out;
}
/** @param array<string,mixed> $rule */
private function syncRule(string $username, string $ruleId, array $rule): void
{
$existing = $this->indexedEntries($this->sync->entriesForRule($username, $ruleId));
$targets = $this->targetMailboxes($username, $rule);
$next = [];
foreach ($targets as $mailbox) {
$email = strtolower((string)$mailbox['email']);
$this->api->saveVacation($username, $email, $rule, isset($existing[$email]));
$next[] = [
'rule_id' => $ruleId,
'domain' => (string)$mailbox['domain'],
'mailbox' => (string)$mailbox['mailbox'],
'email' => $email,
'api_key' => $email,
'last_sync_at' => time(),
];
unset($existing[$email]);
}
foreach ($existing as $entry) {
$this->api->deleteVacation($username, (string)$entry['email']);
}
$this->sync->replaceRuleEntries($username, $ruleId, $next);
}
/** @param array<int,array<string,mixed>> $entries */
private function deleteEntries(string $username, string $ruleId, array $entries): void
{
foreach ($entries as $entry) {
$email = (string)($entry['email'] ?? $entry['api_key'] ?? '');
if ($email !== '') {
$this->api->deleteVacation($username, $email);
}
}
$this->sync->deleteRuleEntries($username, $ruleId);
}
/** @param array<int,array<string,mixed>> $entries @return array<string,array<string,mixed>> */
private function indexedEntries(array $entries): array
{
$out = [];
foreach ($entries as $entry) {
$email = strtolower((string)($entry['email'] ?? $entry['api_key'] ?? ''));
if ($email !== '') {
$out[$email] = $entry + ['email' => $email];
}
}
return $out;
}
/** @param array<string,mixed> $rule @return array<int,array{domain:string,mailbox:string,email:string}> */
private function targetMailboxes(string $username, array $rule): array
{
if (empty($rule['dynamic_scope']) && isset($rule['mailbox_snapshot']) && is_array($rule['mailbox_snapshot'])) {
return array_values(array_filter($rule['mailbox_snapshot'], static function (mixed $mailbox): bool {
return is_array($mailbox)
&& isset($mailbox['domain'], $mailbox['mailbox'], $mailbox['email'])
&& filter_var((string)$mailbox['email'], FILTER_VALIDATE_EMAIL);
}));
}
return $this->mailboxes->mailboxesForUser($username);
}
}