feat: complete production backend integration
This commit is contained in:
@@ -20,6 +20,8 @@ foreach ([
|
||||
'CsrfGuard.php',
|
||||
'RuleRepository.php',
|
||||
'DirectAdminSyncRepository.php',
|
||||
'DirectAdminVacationApi.php',
|
||||
'DirectAdminVacationSyncService.php',
|
||||
'RuleValidator.php',
|
||||
'MailboxDirectory.php',
|
||||
'AuditLog.php',
|
||||
|
||||
@@ -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">‹</button><span class="br-month-label">Czerwiec 2026</span><button type="button" class="br-month-btn">›</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>‹</button><span class="br-month-label" data-calendar-month-label>' . AppContext::e($monthLabel) . '</span><button type="button" class="br-month-btn" data-calendar-next>›</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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']));
|
||||
};
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,6 +2,7 @@ name=global-autoresponder
|
||||
id=global-autoresponder
|
||||
type=user
|
||||
author=HITME.PL
|
||||
version=1.0.5
|
||||
version=1.0.6
|
||||
active=no
|
||||
installed=no
|
||||
user_run_as=root
|
||||
|
||||
+160
-10
@@ -3,6 +3,16 @@ set -eu
|
||||
|
||||
BASE_DIR="/usr/local/hitme_plugins/global-autoresponders"
|
||||
STATE_FILE="$BASE_DIR/config/backend-state.json"
|
||||
PLUGIN_DIR="/usr/local/directadmin/plugins/global-autoresponder"
|
||||
CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim"
|
||||
CUSTOM_CONF="$CUSTOM_DIR/exim.conf"
|
||||
SOURCE_CONF="/usr/local/directadmin/custombuild/configure/exim/exim.conf"
|
||||
ROUTER_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_router.conf"
|
||||
TRANSPORT_SNIPPET="$PLUGIN_DIR/scripts/exim/global_autoresponder_transport.conf"
|
||||
ROLLBACK_ACTIVE=0
|
||||
ROLLBACK_CREATED=0
|
||||
ROLLBACK_REBUILD=0
|
||||
ROLLBACK_FILE=
|
||||
|
||||
usage() {
|
||||
echo "Usage: scripts/backend.sh da|exim" >&2
|
||||
@@ -16,21 +26,161 @@ fi
|
||||
mkdir -p "$BASE_DIR/config" "$BASE_DIR/data" "$BASE_DIR/state" "$BASE_DIR/logs"
|
||||
chmod 700 "$BASE_DIR/config" "$BASE_DIR/logs"
|
||||
|
||||
write_state() {
|
||||
backend="$1"
|
||||
printf '{"active_backend":"%s","migration_status":"complete"}\n' "$backend" > "$STATE_FILE"
|
||||
chmod 600 "$STATE_FILE"
|
||||
}
|
||||
|
||||
remove_marked_block() {
|
||||
marker="$1"
|
||||
file="$2"
|
||||
tmp="${file}.global-autoresponder.$$"
|
||||
awk -v marker="$marker" '
|
||||
$0 == "# BEGIN global-autoresponder " marker { skip=1; next }
|
||||
$0 == "# END global-autoresponder " marker { skip=0; next }
|
||||
skip != 1 { print }
|
||||
' "$file" > "$tmp"
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
insert_after_section() {
|
||||
section="$1"
|
||||
snippet="$2"
|
||||
file="$3"
|
||||
tmp="${file}.global-autoresponder.$$"
|
||||
if ! awk -v section="$section" -v snippet="$snippet" '
|
||||
BEGIN {
|
||||
while ((getline line < snippet) > 0) {
|
||||
block = block line "\n"
|
||||
}
|
||||
close(snippet)
|
||||
}
|
||||
{ print }
|
||||
$0 == section && inserted != 1 {
|
||||
printf "\n%s\n", block
|
||||
inserted = 1
|
||||
}
|
||||
END {
|
||||
if (inserted != 1) {
|
||||
exit 3
|
||||
}
|
||||
}
|
||||
' "$file" > "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
echo "Cannot install exim backend: section $section not found in custom exim.conf." >&2
|
||||
exit 1
|
||||
fi
|
||||
mv "$tmp" "$file"
|
||||
}
|
||||
|
||||
backup_custom_conf() {
|
||||
mkdir -p "$CUSTOM_DIR"
|
||||
ROLLBACK_FILE="${CUSTOM_CONF}.global-autoresponder.backup.$$"
|
||||
if [ -f "$CUSTOM_CONF" ]; then
|
||||
cp "$CUSTOM_CONF" "$ROLLBACK_FILE"
|
||||
ROLLBACK_CREATED=0
|
||||
else
|
||||
: > "$ROLLBACK_FILE"
|
||||
ROLLBACK_CREATED=1
|
||||
fi
|
||||
ROLLBACK_ACTIVE=1
|
||||
}
|
||||
|
||||
restore_custom_conf() {
|
||||
if [ "$ROLLBACK_ACTIVE" != "1" ]; then
|
||||
return
|
||||
fi
|
||||
set +e
|
||||
if [ "$ROLLBACK_CREATED" = "1" ]; then
|
||||
rm -f "$CUSTOM_CONF"
|
||||
else
|
||||
cp "$ROLLBACK_FILE" "$CUSTOM_CONF"
|
||||
fi
|
||||
rm -f "$ROLLBACK_FILE"
|
||||
if [ "$ROLLBACK_REBUILD" = "1" ]; then
|
||||
rebuild_and_reload_exim >/dev/null 2>&1
|
||||
fi
|
||||
ROLLBACK_ACTIVE=0
|
||||
}
|
||||
|
||||
commit_custom_conf() {
|
||||
rm -f "$ROLLBACK_FILE"
|
||||
ROLLBACK_ACTIVE=0
|
||||
ROLLBACK_CREATED=0
|
||||
ROLLBACK_REBUILD=0
|
||||
}
|
||||
|
||||
trap 'restore_custom_conf' EXIT
|
||||
trap 'restore_custom_conf; exit 1' HUP INT TERM
|
||||
|
||||
validate_exim_conf() {
|
||||
if command -v exim >/dev/null 2>&1; then
|
||||
exim -C "$CUSTOM_CONF" -bV >/dev/null
|
||||
elif command -v exim4 >/dev/null 2>&1; then
|
||||
exim4 -C "$CUSTOM_CONF" -bV >/dev/null
|
||||
else
|
||||
echo "Cannot validate exim backend: exim binary not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
rebuild_and_reload_exim() {
|
||||
if command -v da >/dev/null 2>&1; then
|
||||
da build exim_conf
|
||||
elif [ -x /usr/local/directadmin/custombuild/build ]; then
|
||||
/usr/local/directadmin/custombuild/build exim_conf
|
||||
else
|
||||
echo "Cannot rebuild exim configuration: da/build command not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload exim 2>/dev/null || systemctl restart exim
|
||||
else
|
||||
service exim reload 2>/dev/null || service exim restart
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$1" = "da" ]; then
|
||||
cat > "$STATE_FILE" <<'EOF'
|
||||
{"active_backend":"da","migration_status":"complete"}
|
||||
EOF
|
||||
if [ -f "$CUSTOM_CONF" ]; then
|
||||
backup_custom_conf
|
||||
remove_marked_block "router" "$CUSTOM_CONF"
|
||||
remove_marked_block "transport" "$CUSTOM_CONF"
|
||||
if grep -Fq "global_autoresponder_" "$CUSTOM_CONF"; then
|
||||
echo "Cannot switch to da: plugin Exim markers were not removed cleanly." >&2
|
||||
exit 1
|
||||
fi
|
||||
validate_exim_conf
|
||||
ROLLBACK_REBUILD=1
|
||||
rebuild_and_reload_exim
|
||||
commit_custom_conf
|
||||
fi
|
||||
write_state "da"
|
||||
echo "Backend set to da"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CUSTOM_DIR="/usr/local/directadmin/custombuild/custom/exim"
|
||||
if [ ! -d "$CUSTOM_DIR" ]; then
|
||||
echo "Cannot enable exim backend: safe CustomBuild exim customization directory not found." >&2
|
||||
if [ ! -d "$(dirname "$SOURCE_CONF")" ]; then
|
||||
echo "Cannot enable exim backend: DirectAdmin CustomBuild exim source config not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$ROUTER_SNIPPET" ] || [ ! -f "$TRANSPORT_SNIPPET" ]; then
|
||||
echo "Cannot enable exim backend: plugin Exim snippets are missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$STATE_FILE" <<'EOF'
|
||||
{"active_backend":"exim","migration_status":"pending_exim_validation"}
|
||||
EOF
|
||||
echo "Backend prepared for exim; validate Exim customization before production use."
|
||||
backup_custom_conf
|
||||
if [ ! -f "$CUSTOM_CONF" ]; then
|
||||
cp "$SOURCE_CONF" "$CUSTOM_CONF"
|
||||
fi
|
||||
|
||||
remove_marked_block "router" "$CUSTOM_CONF"
|
||||
remove_marked_block "transport" "$CUSTOM_CONF"
|
||||
insert_after_section "begin routers" "$ROUTER_SNIPPET" "$CUSTOM_CONF"
|
||||
insert_after_section "begin transports" "$TRANSPORT_SNIPPET" "$CUSTOM_CONF"
|
||||
validate_exim_conf
|
||||
ROLLBACK_REBUILD=1
|
||||
rebuild_and_reload_exim
|
||||
commit_custom_conf
|
||||
write_state "exim"
|
||||
echo "Backend set to exim"
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
# global-autoresponder router placeholder
|
||||
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
|
||||
# BEGIN global-autoresponder router
|
||||
# Runs an unseen delivery to the plugin worker for local recipients. Normal mailbox
|
||||
# delivery continues because unseen is enabled and the worker suppresses ineligible mail.
|
||||
global_autoresponder_router:
|
||||
driver = accept
|
||||
domains = +local_domains
|
||||
condition = ${if exists{/usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php}{yes}{no}}
|
||||
transport = global_autoresponder_transport
|
||||
unseen
|
||||
no_verify
|
||||
# END global-autoresponder router
|
||||
|
||||
@@ -1,2 +1,13 @@
|
||||
# global-autoresponder transport placeholder
|
||||
# This file must only be installed through DirectAdmin/CustomBuild-safe Exim customization paths.
|
||||
# BEGIN global-autoresponder transport
|
||||
# The worker receives the original message on stdin and Exim-provided envelope
|
||||
# variables such as SENDER, RECIPIENT, LOCAL_PART, and DOMAIN in the environment.
|
||||
global_autoresponder_transport:
|
||||
driver = pipe
|
||||
command = /usr/local/directadmin/plugins/global-autoresponder/scripts/global_autoresponder_worker.php
|
||||
user = root
|
||||
group = root
|
||||
return_fail_output = false
|
||||
log_output = true
|
||||
message_prefix =
|
||||
message_suffix =
|
||||
# END global-autoresponder transport
|
||||
|
||||
@@ -48,6 +48,9 @@ final class GlobalAutoresponderWorker
|
||||
if (!$this->isActiveRule($rule)) {
|
||||
continue;
|
||||
}
|
||||
if (!$this->ruleIncludesRecipient($rule, $recipient)) {
|
||||
continue;
|
||||
}
|
||||
$ruleId = (string)($rule['id'] ?? '');
|
||||
if ($ruleId === '') {
|
||||
continue;
|
||||
@@ -81,6 +84,24 @@ final class GlobalAutoresponderWorker
|
||||
return (int)($rule['start_ts'] ?? 0) <= $now && (int)($rule['end_ts'] ?? 0) >= $now;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $rule */
|
||||
private function ruleIncludesRecipient(array $rule, string $recipient): bool
|
||||
{
|
||||
if (!array_key_exists('dynamic_scope', $rule) || !empty($rule['dynamic_scope'])) {
|
||||
return true;
|
||||
}
|
||||
$snapshot = $rule['mailbox_snapshot'] ?? [];
|
||||
if (!is_array($snapshot)) {
|
||||
return false;
|
||||
}
|
||||
foreach ($snapshot as $mailbox) {
|
||||
if (is_array($mailbox) && strtolower((string)($mailbox['email'] ?? '')) === strtolower($recipient)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string,string> $env @param string[] $keys */
|
||||
private static function firstEnv(array $env, array $keys): string
|
||||
{
|
||||
|
||||
@@ -2,4 +2,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
echo "DirectAdmin vacation synchronization is executed by plugin handlers when DA backend is active.\n";
|
||||
define('PLUGIN_ROOT', dirname(__DIR__));
|
||||
define('PLUGIN_EXEC_DIR', PLUGIN_ROOT . '/exec');
|
||||
|
||||
foreach ([
|
||||
'Settings.php',
|
||||
'RuleRepository.php',
|
||||
'DirectAdminSyncRepository.php',
|
||||
'MailboxDirectory.php',
|
||||
'DirectAdminVacationApi.php',
|
||||
'DirectAdminVacationSyncService.php',
|
||||
] as $file) {
|
||||
require_once PLUGIN_EXEC_DIR . '/lib/' . $file;
|
||||
}
|
||||
|
||||
function sync_usage(): void
|
||||
{
|
||||
fwrite(STDERR, "Usage: sync_directadmin_vacations.php [--user=USERNAME]\n");
|
||||
}
|
||||
|
||||
try {
|
||||
$targetUser = '';
|
||||
foreach (array_slice($_SERVER['argv'] ?? [], 1) as $arg) {
|
||||
if (str_starts_with($arg, '--user=')) {
|
||||
$targetUser = substr($arg, 7);
|
||||
continue;
|
||||
}
|
||||
sync_usage();
|
||||
exit(2);
|
||||
}
|
||||
if ($targetUser === '') {
|
||||
$targetUser = getenv('USERNAME') ?: getenv('USER') ?: '';
|
||||
}
|
||||
if ($targetUser !== '' && !preg_match('/^[A-Za-z0-9._-]+$/', $targetUser)) {
|
||||
throw new InvalidArgumentException('Invalid username');
|
||||
}
|
||||
|
||||
$settings = Settings::load(Settings::EXTERNAL_SETTINGS);
|
||||
if ($settings->backendMode() !== 'da') {
|
||||
echo "DirectAdmin backend is not active; nothing to sync.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$users = $targetUser !== '' ? [$targetUser] : users_with_rules(Settings::DATA_DIR);
|
||||
$service = new DirectAdminVacationSyncService(
|
||||
new RuleRepository(),
|
||||
new DirectAdminSyncRepository(),
|
||||
new MailboxDirectory(),
|
||||
new DirectAdminVacationApi()
|
||||
);
|
||||
foreach ($users as $username) {
|
||||
$service->syncUser($username);
|
||||
echo "Synced {$username}\n";
|
||||
}
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "DirectAdmin vacation synchronization failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
function users_with_rules(string $baseDir): array
|
||||
{
|
||||
$dir = rtrim($baseDir, '/') . '/users';
|
||||
if (!is_dir($dir)) {
|
||||
return [];
|
||||
}
|
||||
$users = [];
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || !preg_match('/^[A-Za-z0-9._-]+$/', $entry)) {
|
||||
continue;
|
||||
}
|
||||
if (is_file($dir . '/' . $entry . '/rules.json')) {
|
||||
$users[] = $entry;
|
||||
}
|
||||
}
|
||||
sort($users);
|
||||
return $users;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once PLUGIN_ROOT . '/exec/lib/Settings.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/RuleRepository.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminSyncRepository.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/MailboxDirectory.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminVacationApi.php';
|
||||
require_once PLUGIN_ROOT . '/exec/lib/DirectAdminVacationSyncService.php';
|
||||
|
||||
test('directadmin vacation sync creates tracked entries for enabled rule mailboxes', function (): void {
|
||||
$virtualRoot = TEST_TMP . '/da-sync-virtual';
|
||||
test_write($virtualRoot . '/domainowners', "example.com: alice\n");
|
||||
test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\nsales:x:1000:12::/home/alice/imap/example.com/sales:/bin/false\n");
|
||||
|
||||
$repo = new RuleRepository(TEST_TMP . '/da-sync-data');
|
||||
$rule = $repo->create('alice', [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_value' => '2026-06-10|morning',
|
||||
'end_value' => '2026-06-24|evening',
|
||||
'start_ts' => 1,
|
||||
'end_ts' => 2,
|
||||
'enabled' => true,
|
||||
'dynamic_scope' => true,
|
||||
]);
|
||||
|
||||
$calls = [];
|
||||
$api = new class($calls) extends DirectAdminVacationApi {
|
||||
public function __construct(private array &$calls) {}
|
||||
public function saveVacation(string $owner, string $email, array $rule, bool $exists): void
|
||||
{
|
||||
$this->calls[] = ['save', $owner, $email, $exists, $rule['subject']];
|
||||
}
|
||||
public function deleteVacation(string $owner, string $email): void
|
||||
{
|
||||
$this->calls[] = ['delete', $owner, $email];
|
||||
}
|
||||
};
|
||||
|
||||
$sync = new DirectAdminVacationSyncService(
|
||||
$repo,
|
||||
new DirectAdminSyncRepository(TEST_TMP . '/da-sync-data'),
|
||||
new MailboxDirectory($virtualRoot),
|
||||
$api
|
||||
);
|
||||
$sync->syncUser('alice');
|
||||
|
||||
assert_same([
|
||||
['save', 'alice', 'info@example.com', false, 'Urlop'],
|
||||
['save', 'alice', 'sales@example.com', false, 'Urlop'],
|
||||
], $calls);
|
||||
|
||||
$tracked = (new DirectAdminSyncRepository(TEST_TMP . '/da-sync-data'))->entriesForRule('alice', (string)$rule['id']);
|
||||
assert_same(['info@example.com', 'sales@example.com'], array_column($tracked, 'email'));
|
||||
});
|
||||
|
||||
test('directadmin vacation sync deletes stale tracked entries after rule removal', function (): void {
|
||||
$virtualRoot = TEST_TMP . '/da-sync-delete-virtual';
|
||||
test_write($virtualRoot . '/domainowners', "example.com: alice\n");
|
||||
test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\n");
|
||||
|
||||
$dataDir = TEST_TMP . '/da-sync-delete-data';
|
||||
$repo = new RuleRepository($dataDir);
|
||||
$syncRepo = new DirectAdminSyncRepository($dataDir);
|
||||
$rule = $repo->create('alice', [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_value' => '2026-06-10|morning',
|
||||
'end_value' => '2026-06-24|evening',
|
||||
'start_ts' => 1,
|
||||
'end_ts' => 2,
|
||||
'enabled' => true,
|
||||
'dynamic_scope' => true,
|
||||
]);
|
||||
$syncRepo->replaceRuleEntries('alice', (string)$rule['id'], [
|
||||
['email' => 'info@example.com', 'domain' => 'example.com', 'mailbox' => 'info', 'api_key' => 'info@example.com'],
|
||||
]);
|
||||
$repo->delete('alice', (string)$rule['id']);
|
||||
|
||||
$calls = [];
|
||||
$api = new class($calls) extends DirectAdminVacationApi {
|
||||
public function __construct(private array &$calls) {}
|
||||
public function saveVacation(string $owner, string $email, array $rule, bool $exists): void {}
|
||||
public function deleteVacation(string $owner, string $email): void
|
||||
{
|
||||
$this->calls[] = ['delete', $owner, $email];
|
||||
}
|
||||
};
|
||||
|
||||
$sync = new DirectAdminVacationSyncService($repo, $syncRepo, new MailboxDirectory($virtualRoot), $api);
|
||||
$sync->syncUser('alice');
|
||||
|
||||
assert_same([['delete', 'alice', 'info@example.com']], $calls);
|
||||
assert_same([], $syncRepo->entriesForRule('alice', (string)$rule['id']));
|
||||
});
|
||||
@@ -96,3 +96,49 @@ test('exim worker suppresses automated messages and ignores unresolved recipient
|
||||
));
|
||||
assert_same(0, $sent);
|
||||
});
|
||||
|
||||
test('exim worker respects static mailbox snapshot scope', function (): void {
|
||||
$settingsPath = TEST_TMP . '/worker-snapshot-settings.conf';
|
||||
test_write($settingsPath, "GLOBAL_AUTORESPONDER_BACKEND=exim\nGLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE=true\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
|
||||
$virtualRoot = TEST_TMP . '/worker-snapshot-virtual';
|
||||
test_write($virtualRoot . '/domainowners', "example.com: alice\n");
|
||||
test_write($virtualRoot . '/example.com/passwd', "info:x:1000:12::/home/alice/imap/example.com/info:/bin/false\nsales:x:1000:12::/home/alice/imap/example.com/sales:/bin/false\n");
|
||||
|
||||
$repo = new RuleRepository(TEST_TMP . '/worker-snapshot-data');
|
||||
$repo->create('alice', [
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'start_ts' => 1,
|
||||
'end_ts' => 2000,
|
||||
'enabled' => true,
|
||||
'dynamic_scope' => false,
|
||||
'mailbox_snapshot' => [
|
||||
['domain' => 'example.com', 'mailbox' => 'info', 'email' => 'info@example.com'],
|
||||
],
|
||||
]);
|
||||
|
||||
$sent = 0;
|
||||
$worker = new GlobalAutoresponderWorker(
|
||||
$settings,
|
||||
$repo,
|
||||
new MailboxDirectory($virtualRoot),
|
||||
new RepeatState(TEST_TMP . '/worker-snapshot-state', 1000),
|
||||
static function () use (&$sent): bool {
|
||||
$sent++;
|
||||
return true;
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
assert_same(0, $worker->handle(
|
||||
['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'sales@example.com'],
|
||||
"Auto-Submitted: no\n\nBody"
|
||||
));
|
||||
assert_same(1, $worker->handle(
|
||||
['SENDER_ADDRESS' => 'person@example.net', 'RECIPIENT' => 'info@example.com'],
|
||||
"Auto-Submitted: no\n\nBody"
|
||||
));
|
||||
assert_same(1, $sent);
|
||||
});
|
||||
|
||||
@@ -76,3 +76,39 @@ test('rule form uses native period selector in da mode', function (): void {
|
||||
assert_contains('Rano', $html);
|
||||
assert_false(strpos($html, 'type="time"') !== false);
|
||||
});
|
||||
|
||||
test('mutating handlers synchronize active backend after storage changes', function (): void {
|
||||
foreach (['create.php', 'update.php', 'delete.php', 'toggle.php'] as $handler) {
|
||||
$source = file_get_contents(PLUGIN_ROOT . '/exec/handlers/' . $handler) ?: '';
|
||||
assert_contains('syncActiveBackend', $source, $handler . ' must synchronize backend after mutation');
|
||||
}
|
||||
});
|
||||
|
||||
test('calendar picker is based on selected rule month and supports month navigation', function (): void {
|
||||
$settingsPath = TEST_TMP . '/form-calendar.conf';
|
||||
test_write($settingsPath, "DEFAULT_PLUGIN_LANGUAGE=pl\nGLOBAL_AUTORESPONDER_BACKEND=exim\n");
|
||||
$settings = Settings::load($settingsPath);
|
||||
$ctx = new AppContext(
|
||||
new DirectAdminUser('alice', 'enhanced', 'pl', TEST_TMP . '/config'),
|
||||
$settings,
|
||||
new RuleRepository(TEST_TMP . '/data'),
|
||||
new CsrfGuard('secret', 'alice', 'sid'),
|
||||
Lang::load('pl', $settings),
|
||||
new AuditLog(TEST_TMP . '/audit.log')
|
||||
);
|
||||
|
||||
$html = render_rule_form($ctx, 'update', [
|
||||
'id' => 'abc',
|
||||
'subject' => 'Urlop',
|
||||
'body' => 'Body',
|
||||
'enabled' => true,
|
||||
'start_value' => '2027-02-14T20:00',
|
||||
'end_value' => '2027-03-01T09:30',
|
||||
]);
|
||||
|
||||
assert_contains('data-calendar-picker', $html);
|
||||
assert_contains('data-calendar-prev', $html);
|
||||
assert_contains('data-calendar-next', $html);
|
||||
assert_contains('Luty 2027', $html);
|
||||
assert_false(strpos($html, 'Czerwiec 2026') !== false);
|
||||
});
|
||||
|
||||
@@ -7,3 +7,40 @@ test('packing guidelines exclude docs and allow later manual icon', function ():
|
||||
assert_contains('must not contain `docs/`', $packing);
|
||||
assert_contains('may be added later at `src/images/user_icon.svg`', $packing);
|
||||
});
|
||||
|
||||
test('plugin metadata runs user level as root on directadmin 1.689 plus', function (): void {
|
||||
$conf = file_get_contents(PLUGIN_ROOT . '/plugin.conf') ?: '';
|
||||
assert_contains('type=user', $conf);
|
||||
assert_contains('user_run_as=root', $conf);
|
||||
});
|
||||
|
||||
test('shipped plugin files do not contain placeholders', function (): void {
|
||||
$bad = [];
|
||||
$root = new RecursiveDirectoryIterator(PLUGIN_ROOT, FilesystemIterator::SKIP_DOTS);
|
||||
$it = new RecursiveIteratorIterator($root);
|
||||
foreach ($it as $file) {
|
||||
if (!$file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
$path = $file->getPathname();
|
||||
$relative = substr($path, strlen(PLUGIN_ROOT) + 1);
|
||||
if (str_starts_with($relative, '.git/') || str_starts_with($relative, 'tests/')) {
|
||||
continue;
|
||||
}
|
||||
$content = file_get_contents($path) ?: '';
|
||||
if (preg_match('/placeholder|TODO|FIXME|not implemented|pending_exim_validation|stub/i', $content)) {
|
||||
$bad[] = $relative;
|
||||
}
|
||||
}
|
||||
assert_same([], $bad);
|
||||
});
|
||||
|
||||
test('backend script protects custom exim config with rollback and exact section insertion checks', function (): void {
|
||||
$script = file_get_contents(PLUGIN_ROOT . '/scripts/backend.sh') ?: '';
|
||||
assert_contains('backup_custom_conf()', $script);
|
||||
assert_contains('restore_custom_conf()', $script);
|
||||
assert_contains("trap 'restore_custom_conf' EXIT", $script);
|
||||
assert_contains('ROLLBACK_REBUILD=1', $script);
|
||||
assert_contains('if ! awk -v section="$section" -v snippet="$snippet"', $script);
|
||||
assert_contains('if (inserted != 1)', $script);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user