feat: complete production backend integration
This commit is contained in:
+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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user