feat: implement global autoresponder plugin
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AppContext
|
||||
{
|
||||
public function __construct(
|
||||
public DirectAdminUser $daUser,
|
||||
public Settings $settings,
|
||||
public RuleRepository $rules,
|
||||
public CsrfGuard $csrf,
|
||||
public Lang $lang,
|
||||
public AuditLog $audit,
|
||||
) {
|
||||
}
|
||||
|
||||
public function action(): string
|
||||
{
|
||||
return (string)PLUGIN_ACTION;
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return '/CMD_PLUGINS/global-autoresponder';
|
||||
}
|
||||
|
||||
/** @param array<string,string|int> $query */
|
||||
public function url(string $page, array $query = []): string
|
||||
{
|
||||
$url = $this->baseUrl() . '/' . ltrim($page, '/');
|
||||
if ($query !== []) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
public function t(string $key): string
|
||||
{
|
||||
return $this->lang->t($key);
|
||||
}
|
||||
|
||||
public function post(string $key, string $default = ''): string
|
||||
{
|
||||
return Http::getString($_POST, $key, $default);
|
||||
}
|
||||
|
||||
public function query(string $key, string $default = ''): string
|
||||
{
|
||||
return Http::getString($_GET, $key, $default);
|
||||
}
|
||||
|
||||
public function requirePost(): void
|
||||
{
|
||||
if (!Http::isPost()) {
|
||||
throw new RuntimeException('Method not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
public function csrfField(string $intent): string
|
||||
{
|
||||
$issued = $this->csrf->issue($intent);
|
||||
return '<input type="hidden" name="csrf_ts" value="' . self::e((string)$issued['ts']) . '">' .
|
||||
'<input type="hidden" name="csrf_sid" value="' . self::e($issued['sid']) . '">' .
|
||||
'<input type="hidden" name="csrf_token" value="' . self::e($issued['token']) . '">';
|
||||
}
|
||||
|
||||
public function requireCsrf(string $intent): void
|
||||
{
|
||||
if (!$this->csrf->validate($intent, $this->post('csrf_ts'), $this->post('csrf_token'), 3600, $this->post('csrf_sid'))) {
|
||||
throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.');
|
||||
}
|
||||
}
|
||||
|
||||
public function render(string $title, string $body, array $ok = [], array $errors = []): void
|
||||
{
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
$alerts = '';
|
||||
foreach ($ok as $msg) {
|
||||
$alerts .= '<div class="alert alert-ok">' . self::e((string)$msg) . '</div>';
|
||||
}
|
||||
foreach ($errors as $msg) {
|
||||
$alerts .= '<div class="alert alert-err">' . self::e((string)$msg) . '</div>';
|
||||
}
|
||||
echo '<!doctype html><html lang="' . self::e($this->lang->code()) . '"><head><meta charset="utf-8">';
|
||||
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
|
||||
echo '<title>' . self::e($this->t($title)) . '</title><style>' . $this->styles() . '</style></head><body>';
|
||||
echo '<div class="wrap"><div class="breadcrumbs">' . self::e($this->t('Dashboard')) . ' <span>></span> ' . self::e($this->t('Global autoresponders')) . '</div>';
|
||||
echo '<header><h1>' . self::e($this->t($title)) . '</h1><p>' . self::e($this->t('Manage automatic replies for account mailboxes')) . '</p></header>';
|
||||
echo '<div class="top-actions"><a class="btn amber' . ($this->action() === 'index' ? ' active' : '') . '" href="' . self::e($this->url('index.html')) . '">' . self::e($this->t('AUTORESPONDERS')) . '</a>';
|
||||
echo '<a class="btn amber' . ($this->action() === 'create' ? ' active' : '') . '" href="' . self::e($this->url('create.html')) . '">' . self::e($this->t('ADD AUTORESPONDER')) . '</a></div>';
|
||||
echo '<section class="main-section">' . $alerts . $this->lang->translateHtml($body) . '</section></div>';
|
||||
echo '<script>' . $this->scripts() . '</script></body></html>';
|
||||
}
|
||||
|
||||
public static function e(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
private function styles(): string
|
||||
{
|
||||
$skin = $this->daUser->skin();
|
||||
$path = PLUGIN_ROOT . '/user/' . $skin . '.css';
|
||||
if (!is_file($path)) {
|
||||
$path = PLUGIN_ROOT . '/user/enhanced.css';
|
||||
}
|
||||
return is_file($path) ? (string)file_get_contents($path) : '';
|
||||
}
|
||||
|
||||
private function scripts(): string
|
||||
{
|
||||
return <<<'JS'
|
||||
(function () {
|
||||
document.querySelectorAll('[data-date-value]').forEach(function (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 = 'Wybrano: ' + (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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AuditLog
|
||||
{
|
||||
public function __construct(private string $path = Settings::AUDIT_LOG)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<string,string|int|bool> $fields */
|
||||
public function record(string $action, string $user, array $fields = []): void
|
||||
{
|
||||
$dir = dirname($this->path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0700, true);
|
||||
}
|
||||
unset($fields['body']);
|
||||
$line = json_encode([
|
||||
'ts' => date('c'),
|
||||
'action' => $action,
|
||||
'user' => $user,
|
||||
'fields' => $fields,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($line !== false) {
|
||||
@file_put_contents($this->path, $line . "\n", FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class CsrfGuard
|
||||
{
|
||||
public function __construct(private string $secret, private string $username, private string $sessionId)
|
||||
{
|
||||
$this->sessionId = $this->sessionId !== '' ? $this->sessionId : 'no_session';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ts:int,sid:string,token:string}
|
||||
*/
|
||||
public function issue(string $intent): array
|
||||
{
|
||||
$ts = time();
|
||||
return ['ts' => $ts, 'sid' => $this->sessionId, 'token' => $this->buildToken($intent, $ts, $this->sessionId)];
|
||||
}
|
||||
|
||||
public function validate(string $intent, string $timestamp, string $token, int $ttl = 3600, string $sessionHint = ''): bool
|
||||
{
|
||||
if (!preg_match('/^[0-9]{1,20}$/', $timestamp)) {
|
||||
return false;
|
||||
}
|
||||
$ts = (int)$timestamp;
|
||||
$now = time();
|
||||
if ($ts <= 0 || $ts > $now + 30 || $now - $ts > $ttl) {
|
||||
return false;
|
||||
}
|
||||
$sids = array_values(array_unique(array_filter([$sessionHint, $this->sessionId, 'no_session'], static fn ($v): bool => $v !== '')));
|
||||
foreach ($sids as $sid) {
|
||||
if (hash_equals($this->buildToken($intent, $ts, $sid), trim($token))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function buildToken(string $intent, int $timestamp, string $sid): string
|
||||
{
|
||||
return hash_hmac('sha256', implode('|', [$this->username, $sid, $intent, (string)$timestamp]), $this->secret);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DirectAdminSyncRepository
|
||||
{
|
||||
public function __construct(private string $baseDir = Settings::DATA_DIR)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<int,array<string,mixed>> $entries */
|
||||
public function replaceRuleEntries(string $username, string $ruleId, array $entries): void
|
||||
{
|
||||
$data = $this->read($username);
|
||||
$data['rules'][$ruleId] = array_values($entries);
|
||||
$this->write($username, $data);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function entriesForRule(string $username, string $ruleId): array
|
||||
{
|
||||
$data = $this->read($username);
|
||||
return array_values($data['rules'][$ruleId] ?? []);
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function deleteRuleEntries(string $username, string $ruleId): array
|
||||
{
|
||||
$data = $this->read($username);
|
||||
$entries = array_values($data['rules'][$ruleId] ?? []);
|
||||
unset($data['rules'][$ruleId]);
|
||||
$this->write($username, $data);
|
||||
return $entries;
|
||||
}
|
||||
|
||||
/** @return array{rules:array<string,array<int,array<string,mixed>>>} */
|
||||
private function read(string $username): array
|
||||
{
|
||||
$path = $this->path($username);
|
||||
if (!is_file($path)) {
|
||||
return ['rules' => []];
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []];
|
||||
}
|
||||
|
||||
/** @param array{rules:array<string,array<int,array<string,mixed>>>} $data */
|
||||
private function write(string $username, array $data): void
|
||||
{
|
||||
$path = $this->path($username);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
$tmp = $path . '.tmp.' . getmypid();
|
||||
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@chmod($tmp, 0600);
|
||||
rename($tmp, $path);
|
||||
}
|
||||
|
||||
private function path(string $username): string
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
|
||||
throw new InvalidArgumentException('Invalid username');
|
||||
}
|
||||
return rtrim($this->baseDir, '/') . '/users/' . $username . '/da-sync.json';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DirectAdminUser
|
||||
{
|
||||
private string $username;
|
||||
private string $skin;
|
||||
private string $language;
|
||||
private string $configDir;
|
||||
|
||||
public function __construct(string $username, string $skin = 'enhanced', string $language = 'en', string $configDir = Settings::CONFIG_DIR)
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
|
||||
throw new InvalidArgumentException('Invalid username');
|
||||
}
|
||||
$this->username = $username;
|
||||
$this->skin = $skin !== '' ? $skin : 'enhanced';
|
||||
$this->language = $language !== '' ? strtolower($language) : 'en';
|
||||
$this->configDir = $configDir;
|
||||
}
|
||||
|
||||
public static function fromEnvironment(Settings $settings): self
|
||||
{
|
||||
$username = getenv('USERNAME') ?: getenv('USER') ?: getenv('DA_USER') ?: '';
|
||||
if ($username === '' && isset($_SERVER['USERNAME'])) {
|
||||
$username = (string)$_SERVER['USERNAME'];
|
||||
}
|
||||
if ($username === '') {
|
||||
throw new RuntimeException('Cannot determine DirectAdmin user');
|
||||
}
|
||||
$skin = getenv('SKIN') ?: (string)($_SERVER['SKIN'] ?? 'enhanced');
|
||||
$lang = self::readLanguage($username) ?: $settings->defaultLanguage();
|
||||
return new self($username, $skin, $lang);
|
||||
}
|
||||
|
||||
public function username(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function skin(): string
|
||||
{
|
||||
return $this->skin;
|
||||
}
|
||||
|
||||
public function language(): string
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function hasPluginAccess(Settings $settings): bool
|
||||
{
|
||||
$override = $this->configDir . '/users/' . $this->username . '.conf';
|
||||
if (is_file($override)) {
|
||||
$data = Settings::load($override);
|
||||
return $data->getBool('enabled', $settings->defaultEnable());
|
||||
}
|
||||
return $settings->defaultEnable();
|
||||
}
|
||||
|
||||
private static function readLanguage(string $username): string
|
||||
{
|
||||
$path = '/usr/local/directadmin/data/users/' . $username . '/user.conf';
|
||||
if (!is_file($path)) {
|
||||
return '';
|
||||
}
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($lines === false) {
|
||||
return '';
|
||||
}
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, 'language=')) {
|
||||
return strtolower(trim(substr($line, 9)));
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class DirectAdminVacationApi
|
||||
{
|
||||
public function __construct(private string $directadminBinary = '/usr/local/directadmin/directadmin')
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $rule @return array<string,string> */
|
||||
public function buildSavePayload(string $email, array $rule): array
|
||||
{
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new InvalidArgumentException('Invalid mailbox email');
|
||||
}
|
||||
[$local, $domain] = explode('@', strtolower($email), 2);
|
||||
[$startDate, $startTime] = $this->splitPeriod((string)($rule['start_value'] ?? ''));
|
||||
[$endDate, $endTime] = $this->splitPeriod((string)($rule['end_value'] ?? ''));
|
||||
return [
|
||||
'action' => 'modify',
|
||||
'domain' => $domain,
|
||||
'user' => $local,
|
||||
'email' => $email,
|
||||
'text' => (string)($rule['body'] ?? ''),
|
||||
'subject' => (string)($rule['subject'] ?? ''),
|
||||
'startdate' => $startDate,
|
||||
'starttime' => $startTime,
|
||||
'enddate' => $endDate,
|
||||
'endtime' => $endTime,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} */
|
||||
private function splitPeriod(string $value): array
|
||||
{
|
||||
if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) {
|
||||
throw new InvalidArgumentException('DirectAdmin API backend requires directadmin_period values');
|
||||
}
|
||||
return [$m[1], $m[2]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Http
|
||||
{
|
||||
public static function method(): string
|
||||
{
|
||||
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||
}
|
||||
|
||||
public static function isPost(): bool
|
||||
{
|
||||
return self::method() === 'POST';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $source
|
||||
*/
|
||||
public static function getString(array $source, string $key, string $default = ''): string
|
||||
{
|
||||
$value = $source[$key] ?? $default;
|
||||
return is_scalar($value) ? trim((string)$value) : $default;
|
||||
}
|
||||
|
||||
public static function redirect(string $url): void
|
||||
{
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Lang
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private array $map;
|
||||
private string $code;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $map
|
||||
*/
|
||||
private function __construct(string $code, array $map)
|
||||
{
|
||||
$this->code = $code;
|
||||
$this->map = $map;
|
||||
}
|
||||
|
||||
public static function load(string $preferred, Settings $settings): self
|
||||
{
|
||||
$candidates = [strtolower(trim($preferred)), $settings->defaultLanguage(), 'en'];
|
||||
foreach ($candidates as $code) {
|
||||
if (!preg_match('/^[a-z]{2}$/', $code)) {
|
||||
continue;
|
||||
}
|
||||
$path = PLUGIN_ROOT . '/lang/' . $code . '.php';
|
||||
if (is_file($path)) {
|
||||
$data = require $path;
|
||||
return new self($code, is_array($data) ? $data : []);
|
||||
}
|
||||
}
|
||||
return new self('en', []);
|
||||
}
|
||||
|
||||
public function code(): string
|
||||
{
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string|int|float> $vars
|
||||
*/
|
||||
public function t(string $key, array $vars = []): string
|
||||
{
|
||||
$text = $this->map[$key] ?? $key;
|
||||
foreach ($vars as $var => $value) {
|
||||
$text = str_replace('{' . $var . '}', (string)$value, $text);
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
public function translateHtml(string $html): string
|
||||
{
|
||||
if ($html === '' || empty($this->map)) {
|
||||
return $html;
|
||||
}
|
||||
$keys = array_keys($this->map);
|
||||
usort($keys, static fn (string $a, string $b): int => strlen($b) <=> strlen($a));
|
||||
return str_replace($keys, array_map(fn (string $k): string => $this->map[$k], $keys), $html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class LocalizedException extends RuntimeException
|
||||
{
|
||||
/** @param array<string,string|int|float> $vars */
|
||||
public function __construct(private string $key, private array $vars = [])
|
||||
{
|
||||
parent::__construct($key);
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
/** @return array<string,string|int|float> */
|
||||
public function vars(): array
|
||||
{
|
||||
return $this->vars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class MailSender
|
||||
{
|
||||
public static function buildMessage(string $from, string $to, string $subject, string $body): string
|
||||
{
|
||||
foreach ([$from, $to, $subject] as $value) {
|
||||
if (str_contains($value, "\r") || str_contains($value, "\n")) {
|
||||
throw new InvalidArgumentException('Invalid subject or address header');
|
||||
}
|
||||
}
|
||||
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
$headers = [
|
||||
'From: ' . $from,
|
||||
'To: ' . $to,
|
||||
'Subject: ' . $encodedSubject,
|
||||
'Auto-Submitted: auto-replied',
|
||||
'Content-Type: text/plain; charset=UTF-8',
|
||||
'Content-Transfer-Encoding: 8bit',
|
||||
];
|
||||
return implode("\n", $headers) . "\n\n" . str_replace(["\r\n", "\r"], "\n", $body) . "\n";
|
||||
}
|
||||
|
||||
public function send(string $sendmail, string $from, string $to, string $subject, string $body): bool
|
||||
{
|
||||
$message = self::buildMessage($from, $to, $subject, $body);
|
||||
if (!is_file($sendmail) || !is_executable($sendmail)) {
|
||||
return false;
|
||||
}
|
||||
$process = proc_open([$sendmail, '-t', '-i'], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) {
|
||||
return false;
|
||||
}
|
||||
fwrite($pipes[0], $message);
|
||||
fclose($pipes[0]);
|
||||
stream_get_contents($pipes[1]);
|
||||
stream_get_contents($pipes[2]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return proc_close($process) === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class MailboxDirectory
|
||||
{
|
||||
public function __construct(private string $virtualRoot = '/etc/virtual')
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<int,array{domain:string,mailbox:string,email:string}> */
|
||||
public function mailboxesForUser(string $username): array
|
||||
{
|
||||
$domains = $this->domainsForUser($username);
|
||||
$out = [];
|
||||
foreach ($domains as $domain) {
|
||||
$passwd = $this->virtualRoot . '/' . $domain . '/passwd';
|
||||
if (!is_file($passwd)) {
|
||||
continue;
|
||||
}
|
||||
$lines = file($passwd, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($lines as $line) {
|
||||
$local = trim(strtok($line, ':') ?: '');
|
||||
if ($local === '' || !preg_match('/^[A-Za-z0-9._+-]+$/', $local)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['domain' => $domain, 'mailbox' => $local, 'email' => $local . '@' . $domain];
|
||||
}
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => strcmp($a['email'], $b['email']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private function domainsForUser(string $username): array
|
||||
{
|
||||
$path = $this->virtualRoot . '/domainowners';
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
$domains = [];
|
||||
foreach (file($path, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||
if (!str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
[$domain, $owner] = array_map('trim', explode(':', $line, 2));
|
||||
if ($owner !== $username || !preg_match('/^[A-Za-z0-9.-]+$/', $domain)) {
|
||||
continue;
|
||||
}
|
||||
$domains[] = strtolower($domain);
|
||||
}
|
||||
sort($domains);
|
||||
return $domains;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class MessageClassifier
|
||||
{
|
||||
/** @param array<string,string> $headers */
|
||||
public static function shouldSuppress(array $headers, string $sender): bool
|
||||
{
|
||||
$sender = strtolower(trim($sender));
|
||||
if ($sender === '' || str_starts_with($sender, 'mailer-daemon') || str_starts_with($sender, 'postmaster')) {
|
||||
return true;
|
||||
}
|
||||
$normalized = [];
|
||||
foreach ($headers as $key => $value) {
|
||||
$normalized[strtolower(trim($key))] = strtolower(trim($value));
|
||||
}
|
||||
if (($normalized['auto-submitted'] ?? 'no') !== 'no') {
|
||||
return true;
|
||||
}
|
||||
if (in_array($normalized['precedence'] ?? '', ['bulk', 'list', 'junk'], true)) {
|
||||
return true;
|
||||
}
|
||||
if (($normalized['list-id'] ?? '') !== '') {
|
||||
return true;
|
||||
}
|
||||
if (str_contains($normalized['content-type'] ?? '', 'delivery-status')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RepeatState
|
||||
{
|
||||
public function __construct(private string $baseDir = Settings::STATE_DIR, private ?int $now = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function shouldSend(string $owner, string $ruleId, string $recipient, string $sender, int $repeatMinutes): bool
|
||||
{
|
||||
if ($repeatMinutes <= 0) {
|
||||
return true;
|
||||
}
|
||||
$path = $this->path($owner, $ruleId, $recipient, $sender);
|
||||
if (!is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
$last = is_array($data) ? (int)($data['last_sent_at'] ?? 0) : 0;
|
||||
return ($this->time() - $last) >= ($repeatMinutes * 60);
|
||||
}
|
||||
|
||||
public function record(string $owner, string $ruleId, string $recipient, string $sender): void
|
||||
{
|
||||
$path = $this->path($owner, $ruleId, $recipient, $sender);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
file_put_contents($path, json_encode(['last_sent_at' => $this->time()], JSON_PRETTY_PRINT), LOCK_EX);
|
||||
@chmod($path, 0600);
|
||||
}
|
||||
|
||||
private function path(string $owner, string $ruleId, string $recipient, string $sender): string
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._-]+$/', $owner)) {
|
||||
throw new InvalidArgumentException('Invalid owner');
|
||||
}
|
||||
$hash = hash('sha256', implode('|', [$owner, $ruleId, strtolower($recipient), strtolower($sender)]));
|
||||
return rtrim($this->baseDir, '/') . '/replies/' . $owner . '/' . $hash . '.json';
|
||||
}
|
||||
|
||||
private function time(): int
|
||||
{
|
||||
return $this->now ?? time();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RuleRepository
|
||||
{
|
||||
public function __construct(private string $baseDir = Settings::DATA_DIR)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<int,array<string,mixed>> */
|
||||
public function all(string $username): array
|
||||
{
|
||||
$data = $this->read($username);
|
||||
return array_values($data['rules'] ?? []);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find(string $username, string $id): ?array
|
||||
{
|
||||
foreach ($this->all($username) as $rule) {
|
||||
if (($rule['id'] ?? '') === $id) {
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $rule @return array<string,mixed> */
|
||||
public function create(string $username, array $rule): array
|
||||
{
|
||||
$data = $this->read($username);
|
||||
$now = time();
|
||||
$rule['id'] = bin2hex(random_bytes(12));
|
||||
$rule['created_at'] = $now;
|
||||
$rule['updated_at'] = $now;
|
||||
$data['rules'][] = $rule;
|
||||
$this->write($username, $data);
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $patch @return array<string,mixed> */
|
||||
public function update(string $username, string $id, array $patch): array
|
||||
{
|
||||
$data = $this->read($username);
|
||||
foreach ($data['rules'] as $i => $rule) {
|
||||
if (($rule['id'] ?? '') === $id) {
|
||||
$updated = array_merge($rule, $patch, ['id' => $id, 'updated_at' => time()]);
|
||||
$data['rules'][$i] = $updated;
|
||||
$this->write($username, $data);
|
||||
return $updated;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Rule not found');
|
||||
}
|
||||
|
||||
public function delete(string $username, string $id): void
|
||||
{
|
||||
$data = $this->read($username);
|
||||
$before = count($data['rules']);
|
||||
$data['rules'] = array_values(array_filter($data['rules'], static fn (array $rule): bool => ($rule['id'] ?? '') !== $id));
|
||||
if (count($data['rules']) === $before) {
|
||||
throw new RuntimeException('Rule not found');
|
||||
}
|
||||
$this->write($username, $data);
|
||||
}
|
||||
|
||||
/** @return array{rules:array<int,array<string,mixed>>} */
|
||||
private function read(string $username): array
|
||||
{
|
||||
$path = $this->path($username);
|
||||
if (!is_file($path)) {
|
||||
return ['rules' => []];
|
||||
}
|
||||
$data = json_decode((string)file_get_contents($path), true);
|
||||
return is_array($data) && isset($data['rules']) && is_array($data['rules']) ? $data : ['rules' => []];
|
||||
}
|
||||
|
||||
/** @param array{rules:array<int,array<string,mixed>>} $data */
|
||||
private function write(string $username, array $data): void
|
||||
{
|
||||
$path = $this->path($username);
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
$tmp = $path . '.tmp.' . getmypid();
|
||||
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), LOCK_EX);
|
||||
@chmod($tmp, 0600);
|
||||
rename($tmp, $path);
|
||||
}
|
||||
|
||||
private function path(string $username): string
|
||||
{
|
||||
$this->assertUsername($username);
|
||||
return rtrim($this->baseDir, '/') . '/users/' . $username . '/rules.json';
|
||||
}
|
||||
|
||||
private function assertUsername(string $username): void
|
||||
{
|
||||
if (!preg_match('/^[A-Za-z0-9._-]+$/', $username)) {
|
||||
throw new InvalidArgumentException('Invalid username');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class RuleValidator
|
||||
{
|
||||
/** @param array<string,mixed> $input @return array<string,mixed> */
|
||||
public static function validate(array $input, Settings $settings): array
|
||||
{
|
||||
$subject = trim((string)($input['subject'] ?? ''));
|
||||
if ($subject === '') {
|
||||
throw new InvalidArgumentException('Temat jest wymagany.');
|
||||
}
|
||||
if (str_contains($subject, "\r") || str_contains($subject, "\n")) {
|
||||
throw new InvalidArgumentException('Temat nie może zawierać nowych linii.');
|
||||
}
|
||||
if (strlen($subject) > $settings->maxSubjectBytes()) {
|
||||
throw new InvalidArgumentException('Temat jest za długi.');
|
||||
}
|
||||
|
||||
$body = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], (string)($input['body'] ?? ''));
|
||||
$body = trim($body);
|
||||
if ($body === '') {
|
||||
throw new InvalidArgumentException('Treść wiadomości jest wymagana.');
|
||||
}
|
||||
if (strlen($body) > $settings->maxBodyBytes()) {
|
||||
throw new InvalidArgumentException('Treść wiadomości jest za długa.');
|
||||
}
|
||||
|
||||
$mode = $settings->scheduleMode();
|
||||
$tz = new DateTimeZone($settings->timezone());
|
||||
[$startTs, $startValue] = self::parseScheduleValue((string)($input['start_value'] ?? ''), $mode, $tz, true);
|
||||
[$endTs, $endValue] = self::parseScheduleValue((string)($input['end_value'] ?? ''), $mode, $tz, false);
|
||||
if ($endTs <= $startTs) {
|
||||
throw new InvalidArgumentException('Data zakończenia musi być późniejsza niż data rozpoczęcia.');
|
||||
}
|
||||
|
||||
return [
|
||||
'subject' => $subject,
|
||||
'body' => $body,
|
||||
'schedule_mode' => $mode,
|
||||
'timezone' => $settings->timezone(),
|
||||
'start_value' => $startValue,
|
||||
'end_value' => $endValue,
|
||||
'start_ts' => $startTs,
|
||||
'end_ts' => $endTs,
|
||||
'enabled' => self::truthy($input['enabled'] ?? false),
|
||||
'dynamic_scope' => $settings->dynamicMailboxScope(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:string} */
|
||||
private static function parseScheduleValue(string $value, string $mode, DateTimeZone $tz, bool $start): array
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($mode === 'exact_hour') {
|
||||
if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:00$/', $value)) {
|
||||
throw new InvalidArgumentException('Nieprawidłowa data lub godzina.');
|
||||
}
|
||||
$dt = DateTimeImmutable::createFromFormat('!Y-m-d\TH:i', $value, $tz);
|
||||
if (!$dt) {
|
||||
throw new InvalidArgumentException('Nieprawidłowa data lub godzina.');
|
||||
}
|
||||
return [$dt->getTimestamp(), $value];
|
||||
}
|
||||
|
||||
if (!preg_match('/^([0-9]{4}-[0-9]{2}-[0-9]{2})\|(morning|afternoon|evening)$/', $value, $m)) {
|
||||
throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.');
|
||||
}
|
||||
$hour = ['morning' => 8, 'afternoon' => 13, 'evening' => 18][$m[2]];
|
||||
if (!$start && $m[2] === 'evening') {
|
||||
$hour = 23;
|
||||
}
|
||||
$dt = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $m[1] . ' ' . sprintf('%02d:00', $hour), $tz);
|
||||
if (!$dt) {
|
||||
throw new InvalidArgumentException('Nieprawidłowa data lub pora dnia.');
|
||||
}
|
||||
return [$dt->getTimestamp(), $value];
|
||||
}
|
||||
|
||||
private static function truthy(mixed $value): bool
|
||||
{
|
||||
return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class Settings
|
||||
{
|
||||
public const BASE_DIR = '/usr/local/hitme_plugins/global-autoresponders';
|
||||
public const CONFIG_DIR = self::BASE_DIR . '/config';
|
||||
public const DATA_DIR = self::BASE_DIR . '/data';
|
||||
public const STATE_DIR = self::BASE_DIR . '/state';
|
||||
public const LOG_DIR = self::BASE_DIR . '/logs';
|
||||
public const AUDIT_LOG = self::LOG_DIR . '/audit.log';
|
||||
public const EXTERNAL_SETTINGS = self::CONFIG_DIR . '/plugin-settings.conf';
|
||||
|
||||
/** @var array<string,string> */
|
||||
private array $values;
|
||||
|
||||
/**
|
||||
* @param array<string,string> $values
|
||||
*/
|
||||
private function __construct(array $values)
|
||||
{
|
||||
$this->values = $values;
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
public static function load(string $path): self
|
||||
{
|
||||
$values = self::defaults();
|
||||
if (is_file($path)) {
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES);
|
||||
if ($lines !== false) {
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$values[trim($key)] = trim($value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new self($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'DEFAULT_PLUGIN_LANGUAGE' => 'en',
|
||||
'DEFAULT_ENABLE' => 'true',
|
||||
'GLOBAL_AUTORESPONDER_TIMEZONE' => 'Europe/Warsaw',
|
||||
'GLOBAL_AUTORESPONDER_SCHEDULE_MODE' => 'exact_hour',
|
||||
'GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE' => 'false',
|
||||
'GLOBAL_AUTORESPONDER_REPEAT_MINUTES' => '1440',
|
||||
'GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE' => 'true',
|
||||
'GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES' => '255',
|
||||
'GLOBAL_AUTORESPONDER_MAX_BODY_BYTES' => '20000',
|
||||
];
|
||||
}
|
||||
|
||||
public function getString(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->values[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getBool(string $key, bool $default = false): bool
|
||||
{
|
||||
$raw = strtolower($this->getString($key, $default ? 'true' : 'false'));
|
||||
return in_array($raw, ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public function getInt(string $key, int $default): int
|
||||
{
|
||||
$raw = $this->getString($key, (string)$default);
|
||||
return preg_match('/^-?[0-9]+$/', $raw) ? (int)$raw : $default;
|
||||
}
|
||||
|
||||
public function defaultLanguage(): string
|
||||
{
|
||||
$lang = strtolower($this->getString('DEFAULT_PLUGIN_LANGUAGE', 'en'));
|
||||
return preg_match('/^[a-z]{2}$/', $lang) ? $lang : 'en';
|
||||
}
|
||||
|
||||
public function defaultEnable(): bool
|
||||
{
|
||||
return $this->getBool('DEFAULT_ENABLE', true);
|
||||
}
|
||||
|
||||
public function timezone(): string
|
||||
{
|
||||
return $this->getString('GLOBAL_AUTORESPONDER_TIMEZONE', 'Europe/Warsaw');
|
||||
}
|
||||
|
||||
public function scheduleMode(): string
|
||||
{
|
||||
return $this->getString('GLOBAL_AUTORESPONDER_SCHEDULE_MODE', 'exact_hour');
|
||||
}
|
||||
|
||||
public function replyEveryMessage(): bool
|
||||
{
|
||||
return $this->getBool('GLOBAL_AUTORESPONDER_REPLY_EVERY_MESSAGE', false);
|
||||
}
|
||||
|
||||
public function repeatMinutes(): int
|
||||
{
|
||||
return $this->getInt('GLOBAL_AUTORESPONDER_REPEAT_MINUTES', 1440);
|
||||
}
|
||||
|
||||
public function dynamicMailboxScope(): bool
|
||||
{
|
||||
return $this->getBool('GLOBAL_AUTORESPONDER_DYNAMIC_MAILBOX_SCOPE', true);
|
||||
}
|
||||
|
||||
public function maxSubjectBytes(): int
|
||||
{
|
||||
return $this->getInt('GLOBAL_AUTORESPONDER_MAX_SUBJECT_BYTES', 255);
|
||||
}
|
||||
|
||||
public function maxBodyBytes(): int
|
||||
{
|
||||
return $this->getInt('GLOBAL_AUTORESPONDER_MAX_BODY_BYTES', 20000);
|
||||
}
|
||||
|
||||
public function loadOrCreateSecret(string $path = self::CONFIG_DIR . '/secret.key'): string
|
||||
{
|
||||
if (is_file($path)) {
|
||||
$secret = trim((string)file_get_contents($path));
|
||||
if ($secret !== '') {
|
||||
return $secret;
|
||||
}
|
||||
}
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0700, true);
|
||||
}
|
||||
$secret = bin2hex(random_bytes(32));
|
||||
file_put_contents($path, $secret . "\n", LOCK_EX);
|
||||
@chmod($path, 0600);
|
||||
return $secret;
|
||||
}
|
||||
|
||||
private function validate(): void
|
||||
{
|
||||
try {
|
||||
new DateTimeZone($this->timezone());
|
||||
} catch (Throwable) {
|
||||
throw new InvalidArgumentException('Invalid timezone: ' . $this->timezone());
|
||||
}
|
||||
if (!in_array($this->scheduleMode(), ['exact_hour', 'directadmin_period'], true)) {
|
||||
throw new InvalidArgumentException('Invalid schedule mode: ' . $this->scheduleMode());
|
||||
}
|
||||
if ($this->repeatMinutes() < 0) {
|
||||
throw new InvalidArgumentException('Invalid repeat minutes');
|
||||
}
|
||||
if ($this->maxSubjectBytes() < 1 || $this->maxBodyBytes() < 1) {
|
||||
throw new InvalidArgumentException('Invalid size limits');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user