[Bezeichnung, Mindestabstand in Sekunden]. */ function nasbk_intervals(): array { return [ 'hourly' => ['Stündlich', 3600], '6h' => ['Alle 6 Stunden', 21600], '12h' => ['Alle 12 Stunden', 43200], 'daily' => ['Täglich', 86400], 'weekly' => ['Wöchentlich', 604800], 'monthly' => ['Monatlich', 2592000], ]; } /** Alle Einstellungen des Plugins in einem Array (mit Werten für Altbestände). */ function nasbk_config(): array { $pass = (string) plugin_setting(NASBK, 'password', ''); $interval = (string) plugin_setting(NASBK, 'interval', 'daily'); // Verbindung: Domain/IP, Port, HTTPS und Zielverzeichnis. Ältere Einstellungen (ganze Adresse + Ordner) werden weiter verstanden. $host = trim((string) plugin_setting(NASBK, 'host', '')); $port = (int) plugin_setting(NASBK, 'port', '0'); $https = plugin_setting(NASBK, 'https', '1') !== '0'; $targetRaw = plugin_setting(NASBK, 'target', null); // Standard unten setzen (Standardwerte von plugin_setting() werden zwischengespeichert) $target = (string) ($targetRaw ?? ''); if ($host === '' && ($legacy = trim((string) plugin_setting(NASBK, 'url', ''))) !== '') { $p = parse_url($legacy); if ($p && !empty($p['host'])) { $host = $p['host']; $https = strtolower($p['scheme'] ?? 'https') !== 'http'; $port = (int) ($p['port'] ?? 0); $target = trim((string) ($p['path'] ?? ''), '/') . '/' . trim((string) plugin_setting(NASBK, 'folder', 'DeskHub-Backups'), '/'); } } if ($port <= 0) $port = $https ? 443 : 80; if ($target === '' && $targetRaw === null) $target = '/DeskHub-Backups/'; $default = ($https && $port === 443) || (!$https && $port === 80); $url = $host !== '' ? ($https ? 'https' : 'http') . '://' . $host . ($default ? '' : ':' . $port) : ''; // Inhalt des eigenen Backups (Altbestand: Umfang "full"/"database") $legacyMode = plugin_setting(NASBK, 'mode', 'full') === 'database' ? 'database' : 'full'; $db = plugin_setting(NASBK, 'db', '1') === '1'; // Standardwerte hier berechnen, nicht über den Standardwert von plugin_setting() (dieser wird zwischengespeichert) $filesRaw = plugin_setting(NASBK, 'files', null); $files = ($filesRaw ?? ($legacyMode === 'database' ? '0' : '1')) === '1'; $scopeRaw = plugin_setting(NASBK, 'scope', null); $scope = ($scopeRaw ?? ($legacyMode === 'database' ? 'important' : 'full')) === 'important' ? 'important' : 'full'; // Zeit: HH:MM (Altbestand: nur Stunde) $time = (string) plugin_setting(NASBK, 'time', ''); if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) $time = sprintf('%02d:00', max(0, min(23, (int) plugin_setting(NASBK, 'hour', '2')))); return [ 'enabled' => plugin_setting(NASBK, 'enabled', '0') === '1', 'mirror' => plugin_setting(NASBK, 'mirror', '1') === '1', // jedes von DeskHub erstellte Backup auf die NAS kopieren 'own_schedule' => plugin_setting(NASBK, 'own_schedule', '0') === '1', // zusätzlich eigener Zeitplan des Plugins 'host' => $host, 'port' => $port, 'https' => $https, 'target' => $target, 'url' => $url, 'user' => (string) plugin_setting(NASBK, 'user', ''), 'password' => $pass !== '' ? decrypt_secret($pass) : '', 'folder' => trim(trim($target), '/'), 'interval' => isset(nasbk_intervals()[$interval]) ? $interval : 'daily', 'time' => $time, 'hour' => (int) substr($time, 0, 2), 'keep' => max(0, min(365, (int) plugin_setting(NASBK, 'keep', '14'))), // 0 = Anzahl nicht begrenzen 'keep_days' => max(0, min(3650, (int) plugin_setting(NASBK, 'keep_days', '0'))), // 0 = Alter nicht begrenzen 'with_database' => $db, 'with_files' => $files, 'scope' => $scope, 'excludes' => array_values(array_filter(array_map('trim', preg_split('/\r?\n/', (string) plugin_setting(NASBK, 'excludes', '')) ?: []))), 'mode' => $files ? 'full' : 'database', 'verify_tls' => plugin_setting(NASBK, 'verify_tls', '1') !== '0', ]; } /** Prüft die Adresse: https ist Pflicht, http nur für Geräte im eigenen Netz (private oder lokale Adresse). Gibt eine Fehlermeldung oder null zurück. */ /** Prüft die Adresse: https ist Pflicht, http nur für Geräte im eigenen Netz (private oder lokale Adresse). Gibt eine Fehlermeldung oder null zurück. */ function nasbk_url_error(string $url): ?string { if ($url === '') return 'Bitte die Adresse (Domain oder IP) des WebDAV-Servers eintragen.'; $p = parse_url($url); if ($p === false || empty($p['host']) || empty($p['scheme'])) return 'Die Adresse ist ungültig (Beispiel: nas.meinedomain.de).'; if (isset($p['user']) || isset($p['pass']) || isset($p['query']) || isset($p['fragment'])) return 'Die Adresse darf keine Zugangsdaten, Parameter oder Anker enthalten. Benutzer und Passwort kommen in die eigenen Felder.'; $scheme = strtolower($p['scheme']); if (!in_array($scheme, ['http', 'https'], true)) return 'Es sind nur Adressen mit https:// (oder im eigenen Netz http://) erlaubt.'; if (!preg_match('/^[A-Za-z0-9.\-]+$|^\[[0-9a-f:]+\]$/i', $p['host'])) return 'Die Adresse enthält ungültige Zeichen.'; if ($scheme === 'http') { $host = trim($p['host'], '[]'); $local = $host === 'localhost' || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)); if (!$local) return 'Über das Internet ist nur HTTPS erlaubt, sonst würden Passwort und Backup unverschlüsselt übertragen. HTTP geht nur mit einer Adresse im eigenen Netz.'; } return null; } /** Fehler der gesamten Einstellung oder null, wenn alles Nötige da ist. */ function nasbk_config_error(array $cfg): ?string { if ($e = nasbk_url_error($cfg['url'])) return $e; if ($cfg['user'] === '' || $cfg['password'] === '') return 'Bitte Benutzer und Passwort des NAS eintragen.'; foreach (explode('/', $cfg['folder']) as $seg) { if ($seg === '.' || $seg === '..') return 'Der Ordnername ist ungültig.'; } return null; } /** Adresse eines Ordners/einer Datei auf dem Server (Ordnersegmente werden einzeln kodiert). */ function nasbk_remote_url(array $cfg, string $file = '', bool $withFolder = true): string { $url = $cfg['url']; if ($withFolder && $cfg['folder'] !== '') { $url .= '/' . implode('/', array_map('rawurlencode', explode('/', $cfg['folder']))); } return $file !== '' ? $url . '/' . rawurlencode($file) : $url; } /** Ein WebDAV-Aufruf. Optionen: body (Text), file (Pfad zum Hochladen), headers (Liste), timeout (Sekunden). */ function nasbk_request(array $cfg, string $method, string $url, array $opt = []): array { if (!function_exists('curl_init')) return ['code' => 0, 'body' => '', 'error' => 'Auf diesem Server fehlt die PHP-Erweiterung cURL.']; $ch = curl_init($url); $headers = $opt['headers'] ?? []; $fh = null; $set = [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_USERPWD => $cfg['user'] . ':' . $cfg['password'], CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_FOLLOWLOCATION => false, // niemals Zugangsdaten an eine Weiterleitung schicken CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => (int) ($opt['timeout'] ?? 60), CURLOPT_SSL_VERIFYPEER => $cfg['verify_tls'], CURLOPT_SSL_VERIFYHOST => $cfg['verify_tls'] ? 2 : 0, CURLOPT_USERAGENT => 'DeskHub-NAS-Backup', ]; $out = null; if (isset($opt['save_to'])) { $out = fopen($opt['save_to'], 'wb'); if ($out === false) return ['code' => 0, 'body' => '', 'error' => 'Die Zieldatei konnte nicht angelegt werden.']; $set[CURLOPT_RETURNTRANSFER] = false; $set[CURLOPT_FILE] = $out; } if (isset($opt['file'])) { $fh = fopen($opt['file'], 'rb'); if ($fh === false) return ['code' => 0, 'body' => '', 'error' => 'Die Backup-Datei konnte nicht gelesen werden.']; $set[CURLOPT_UPLOAD] = true; $set[CURLOPT_INFILE] = $fh; $set[CURLOPT_INFILESIZE] = filesize($opt['file']); $headers[] = 'Expect:'; } elseif (isset($opt['body'])) { $set[CURLOPT_POSTFIELDS] = $opt['body']; } if ($method === 'HEAD') { $set[CURLOPT_NOBODY] = true; $set[CURLOPT_HEADER] = true; } if ($headers) $set[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $set); $body = curl_exec($ch); $res = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => is_string($body) ? $body : '', 'error' => $body === false ? curl_error($ch) : '']; curl_close($ch); if ($fh) fclose($fh); if ($out) fclose($out); return $res; } /** Verständliche Fehlermeldung zu einer Antwort. */ function nasbk_explain(array $r, string $what): string { $c = $r['code']; if ($c === 0) { $e = $r['error']; $hint = stripos($e, 'ssl') !== false || stripos($e, 'certificate') !== false ? ' Tipp: Hat die NAS ein selbst erstelltes Zertifikat, schalte die Zertifikatsprüfung in den Einstellungen aus (weniger sicher) oder installiere ein Zertifikat von Let’s Encrypt.' : ''; return "{$what}: keine Verbindung ({$e}).{$hint}"; } return match (true) { $c === 401 => "{$what}: Anmeldung abgelehnt. Benutzer oder Passwort stimmen nicht (bei Synology: Benutzer braucht Zugriff auf WebDAV und den Ordner).", $c === 403 => "{$what}: Zugriff verweigert (HTTP 403). Der Benutzer darf dort nicht schreiben.", $c === 404 => "{$what}: Pfad nicht gefunden (HTTP 404). Bei Synology lautet die Adresse https://:5006/.", $c === 405 => "{$what}: Der Server erlaubt diese Aktion nicht (HTTP 405).", $c === 507 => "{$what}: Auf der NAS ist kein Speicherplatz mehr frei (HTTP 507).", default => "{$what}: unerwartete Antwort des Servers (HTTP {$c}).", }; } /** Legt den Ordner (auch verschachtelt) an; vorhandene Ordner sind in Ordnung. Freigaben (oberste Ebene) der NAS müssen schon existieren. */ function nasbk_ensure_folder(array $cfg): ?string { if ($cfg['folder'] === '') return null; $exists = function () use ($cfg): bool { $r = nasbk_request($cfg, 'PROPFIND', nasbk_remote_url($cfg) . '/', ['body' => '', 'headers' => ['Depth: 0', 'Content-Type: application/xml'], 'timeout' => 30]); return $r['code'] === 207; }; if ($exists()) return null; $url = $cfg['url']; $segments = explode('/', $cfg['folder']); $last = count($segments) - 1; foreach ($segments as $i => $seg) { $url .= '/' . rawurlencode($seg); $r = nasbk_request($cfg, 'MKCOL', $url, ['timeout' => 30]); if ($r['code'] === 401) return nasbk_explain($r, 'Ordner anlegen'); // Zwischenebenen gibt es meist schon (405) oder dürfen nicht angelegt werden (403, 409): erst das Ergebnis am Ende zählt if ($i === $last && !in_array($r['code'], [200, 201, 204, 301, 405], true) && !$exists()) return nasbk_explain($r, 'Ordner anlegen'); } return $exists() ? null : 'Ordner anlegen: Der Zielordner „' . $cfg['folder'] . '“ existiert nicht und konnte nicht angelegt werden. Lege ihn auf der NAS an oder prüfe den Pfad (er beginnt mit dem Namen des freigegebenen Ordners).'; } /** Liest die Dateien im Ordner. @return array{ok:bool,items:array,error:string} */ function nasbk_list(array $cfg, int $timeout = 30): array { $body = ''; $r = nasbk_request($cfg, 'PROPFIND', nasbk_remote_url($cfg) . '/', ['body' => $body, 'headers' => ['Depth: 1', 'Content-Type: application/xml'], 'timeout' => $timeout]); if ($r['code'] === 404) return ['ok' => true, 'items' => [], 'error' => '']; if ($r['code'] !== 207) return ['ok' => false, 'items' => [], 'error' => nasbk_explain($r, 'Liste der Sicherungen')]; $prev = libxml_use_internal_errors(true); $xml = simplexml_load_string($r['body'], 'SimpleXMLElement', LIBXML_NONET); libxml_use_internal_errors($prev); if ($xml === false) return ['ok' => false, 'items' => [], 'error' => 'Die Antwort der NAS ließ sich nicht lesen.']; $xml->registerXPathNamespace('d', 'DAV:'); $items = []; foreach ($xml->xpath('//d:response') ?: [] as $resp) { $resp->registerXPathNamespace('d', 'DAV:'); $href = (string) ($resp->xpath('d:href')[0] ?? ''); $name = basename(rawurldecode(rtrim($href, '/'))); if ($name === '' || !preg_match(nasbk_name_pattern(), $name)) continue; $items[] = [ 'name' => $name, 'size' => (int) ($resp->xpath('.//d:getcontentlength')[0] ?? 0), 'modified' => (string) ($resp->xpath('.//d:getlastmodified')[0] ?? ''), ]; } usort($items, fn($a, $b) => strcmp($b['name'], $a['name'])); // Namen enthalten das Datum: neueste zuerst return ['ok' => true, 'items' => $items, 'error' => '']; } /** Nur Dateien, die dieses Plugin selbst anlegt, werden aufgelistet und bei der Aufbewahrung gelöscht. */ function nasbk_name_pattern(): string { return '/^backup_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_[a-z0-9]{3})?\.zip$/'; } /** Größe einer Datei auf dem Server (null, wenn sie sich nicht ermitteln lässt). */ function nasbk_remote_size(array $cfg, string $url): ?int { $r = nasbk_request($cfg, 'PROPFIND', $url, ['body' => '', 'headers' => ['Depth: 0', 'Content-Type: application/xml'], 'timeout' => 30]); if ($r['code'] === 207 && preg_match('#<(?:[a-z0-9]+:)?getcontentlength[^>]*>\s*(\d+)\s*<#i', $r['body'], $m)) return (int) $m[1]; $h = nasbk_request($cfg, 'HEAD', $url, ['timeout' => 30]); if (in_array($h['code'], [200, 204], true) && preg_match('/^Content-Length:\s*(\d+)/im', $h['body'], $m)) return (int) $m[1]; return null; } /** Lädt die Datei hoch und prüft die Größe. Gibt eine Fehlermeldung oder null zurück. */ function nasbk_upload(array $cfg, string $localPath, string $name): ?string { $url = nasbk_remote_url($cfg, $name); $size = (int) filesize($localPath); $timeout = max(300, (int) ceil($size / 1048576) * 20); // mindestens 5 Minuten, sonst rund 20 s je MB $r = nasbk_request($cfg, 'PUT', $url, ['file' => $localPath, 'timeout' => $timeout]); if (!in_array($r['code'], [200, 201, 204], true)) return nasbk_explain($r, 'Hochladen'); $remote = nasbk_remote_size($cfg, $url); if ($remote !== null && $remote !== $size) { nasbk_request($cfg, 'DELETE', $url, ['timeout' => 30]); return "Hochladen: Die Datei auf der NAS ist unvollständig ({$remote} statt {$size} Bytes) und wurde wieder gelöscht."; } return null; } /** Löscht Sicherungen nach den beiden Regeln „älter als X Tage“ und „mehr als Y Stück“ (0 = Regel aus). @return array{deleted:string[],error:string} */ function nasbk_rotate(array $cfg): array { if ($cfg['keep'] <= 0 && $cfg['keep_days'] <= 0) return ['deleted' => [], 'error' => '']; $list = nasbk_list($cfg); if (!$list['ok']) return ['deleted' => [], 'error' => $list['error']]; $items = $list['items']; // neueste zuerst (Namen enthalten das Datum) $drop = []; if ($cfg['keep_days'] > 0) { $cutoff = strtotime("-{$cfg['keep_days']} days"); foreach ($items as $it) { if (preg_match('/^backup_(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})/', $it['name'], $m) && strtotime("{$m[1]} {$m[2]}:{$m[3]}:{$m[4]}") < $cutoff) $drop[$it['name']] = true; } } if ($cfg['keep'] > 0) foreach (array_slice($items, $cfg['keep']) as $it) $drop[$it['name']] = true; $deleted = []; foreach (array_keys($drop) as $name) { $r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]); if (in_array($r['code'], [200, 204, 404], true)) $deleted[] = $name; } return ['deleted' => $deleted, 'error' => '']; } /** Hauptordner von DeskHub (Ort der Datei, die get_db() enthält: includes/db.php). */ function nasbk_root(): string { return dirname((new ReflectionFunction('get_db'))->getFileName(), 2); } /** Löscht einen Ordner samt Inhalt (nur der eigene Zwischenordner im Temp-Verzeichnis). */ function nasbk_rmtree(string $dir): void { if (!is_dir($dir) || !str_contains(basename($dir), 'deskhub-nasbk-')) return; $walk = function (string $d) use (&$walk): void { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; $p = $d . '/' . $e; is_dir($p) && !is_link($p) ? $walk($p) : @unlink($p); } @rmdir($d); }; $walk($dir); } /** * Erzeugt das Backup in einem Zwischenordner (außerhalb des Webverzeichnisses) mit den Einstellungen des Plugins: * Datenbank und/oder Dateien (wichtigste Dateien oder alles), ohne die ausgeschlossenen Pfade. * * @return array{ok:bool,error?:string,path?:string,filename?:string,size?:float,tmp?:string} */ function nasbk_make_archive(array $cfg): array { $root = nasbk_root(); if (!function_exists('create_backup')) require_once $root . '/includes/backup.php'; if (!function_exists('backup_verify')) require_once $root . '/includes/backup_restore.php'; if (!$cfg['with_database'] && !$cfg['with_files']) return ['ok' => false, 'error' => 'Weder Datenbank noch Dateien sind zum Sichern ausgewählt.']; $tmp = sys_get_temp_dir() . '/deskhub-nasbk-' . bin2hex(random_bytes(4)); if (!@mkdir($tmp . '/out', 0700, true)) return ['ok' => false, 'error' => 'Der Zwischenordner für das Backup konnte nicht angelegt werden.']; $options = ['with_database' => $cfg['with_database'], 'with_files' => $cfg['with_files'], 'exclude' => $cfg['excludes'], 'verify' => false]; // das Plugin prüft selbst (ohne Eintrag in die Prüfliste der Backup-Seite) // „Wichtigste Dateien“: Daten, die sich nicht neu herunterladen lassen (Uploads, Plugins, Konfiguration, Logo/Branding) if ($cfg['with_files'] && $cfg['scope'] === 'important') $options['only'] = ['uploads', 'plugins', 'config', 'assets/branding', '.htaccess']; $GLOBALS['nasbk_creating'] = true; // eigenes Backup des Plugins: nicht zusätzlich in die Warteschlange der Backup-Seite aufnehmen try { $res = create_backup($root, $tmp . '/out', null, $options); } finally { $GLOBALS['nasbk_creating'] = false; } if (empty($res['ok'])) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => (string) ($res['error'] ?? 'Backup fehlgeschlagen.')]; } if ($cfg['with_database']) { $check = backup_verify($res['path']); if (empty($check['ok'])) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => 'Das erzeugte Backup ist nicht in Ordnung: ' . implode(' ', (array) ($check['errors'] ?? []))]; } } else { $z = new ZipArchive(); if ($z->open($res['path']) !== true || $z->numFiles < 1) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => 'Das erzeugte Backup lässt sich nicht öffnen oder ist leer.']; } $z->close(); } return ['ok' => true, 'path' => $res['path'], 'filename' => $res['filename'], 'size' => (float) $res['size'], 'tmp' => $tmp]; } /** Hält das Ergebnis fest (Anzeige auf der Einstellungsseite) und meldet Fehler höchstens einmal am Tag per E-Mail. */ function nasbk_record(bool $ok, string $message, bool $manual): void { $now = date('Y-m-d H:i:s'); plugin_set_setting(NASBK, 'last_run', $now); plugin_set_setting(NASBK, 'last_ok', $ok ? '1' : '0'); plugin_set_setting(NASBK, 'last_message', $message); if ($ok) plugin_set_setting(NASBK, 'last_ok_at', $now); $hist = json_decode((string) plugin_setting(NASBK, 'history', '[]'), true); $hist = is_array($hist) ? $hist : []; array_unshift($hist, ['time' => $now, 'ok' => $ok, 'message' => $message, 'manual' => $manual]); plugin_set_setting(NASBK, 'history', json_encode(array_slice($hist, 0, 15), JSON_UNESCAPED_UNICODE)); if (!$ok && !$manual) { $last = strtotime((string) plugin_setting(NASBK, 'last_mail', '')) ?: 0; $to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')); if ($to !== '' && time() - $last > 86400 && function_exists('send_mail')) { plugin_set_setting(NASBK, 'last_mail', $now); @send_mail($to, 'DeskHub: NAS-Backup fehlgeschlagen', '

Das automatische Backup auf die NAS ist fehlgeschlagen:

' . e($message) . '

Details und Einstellungen: Adminportal → NAS-Backup.

'); } } } /** Führt ein Backup samt Upload und Aufbewahrung aus. @return array{ok:bool,message:string} */ function nasbk_run(PDO $pdo, bool $manual = false): array { @set_time_limit(0); @ignore_user_abort(true); $cfg = nasbk_config(); if ($err = nasbk_config_error($cfg)) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; } $arch = nasbk_make_archive($cfg); if (!$arch['ok']) { nasbk_record(false, $arch['error'], $manual); return ['ok' => false, 'message' => $arch['error']]; } try { if ($err = nasbk_ensure_folder($cfg)) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; } if ($err = nasbk_upload($cfg, $arch['path'], $arch['filename'])) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; } $rot = nasbk_rotate($cfg); $msg = $arch['filename'] . ' (' . $arch['size'] . ' MB, ' . ($cfg['mode'] === 'database' ? 'nur Datenbank' : 'komplett') . ') auf die NAS hochgeladen' . ($rot['deleted'] ? '; ' . count($rot['deleted']) . ' alte Sicherung(en) gelöscht' : '') . ($rot['error'] !== '' ? '; Aufbewahrung nicht ausgeführt: ' . $rot['error'] : '') . '.'; nasbk_record(true, $msg, $manual); return ['ok' => true, 'message' => $msg]; } finally { nasbk_rmtree($arch['tmp']); } } /** Läuft gerade ein manuell gestartetes Backup? (Markierung verfällt nach 2 Stunden, falls ein Lauf abgebrochen wurde) */ function nasbk_running(): bool { $t = (int) plugin_setting(NASBK, 'running_since', '0'); return $t > 0 && time() - $t < 7200; } /** Startet ein eigenes Backup nach dem Senden der Antwort (das Seitenladen wartet nicht auf das Backup). */ function nasbk_run_background(PDO $pdo): void { plugin_set_setting(NASBK, 'running_since', (string) time()); register_shutdown_function(function () use ($pdo) { if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request(); @ignore_user_abort(true); try { nasbk_run($pdo, true); } catch (Throwable $e) { nasbk_record(false, 'Backup abgebrochen: ' . $e->getMessage(), true); } finally { plugin_set_setting(NASBK, 'running_since', '0'); } }); } /** Prüft die Verbindung: Ordner anlegen, kleine Testdatei schreiben, Größe lesen, löschen. @return array{ok:bool,message:string} */ function nasbk_test(array $cfg): array { if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err]; if ($err = nasbk_ensure_folder($cfg)) return ['ok' => false, 'message' => $err]; $tmp = tempnam(sys_get_temp_dir(), 'nasbk'); file_put_contents($tmp, 'DeskHub NAS-Backup Verbindungstest ' . date('c')); $name = 'verbindungstest-' . bin2hex(random_bytes(3)) . '.txt'; $url = nasbk_remote_url($cfg, $name); $put = nasbk_request($cfg, 'PUT', $url, ['file' => $tmp, 'timeout' => 60]); @unlink($tmp); if (!in_array($put['code'], [200, 201, 204], true)) return ['ok' => false, 'message' => nasbk_explain($put, 'Testdatei schreiben')]; $del = nasbk_request($cfg, 'DELETE', $url, ['timeout' => 30]); $note = in_array($del['code'], [200, 204], true) ? '' : ' Die Testdatei konnte nicht wieder gelöscht werden (Löschrecht prüfen, sonst bleiben alte Sicherungen liegen).'; return ['ok' => true, 'message' => 'Verbindung in Ordnung: Der Ordner „' . ($cfg['folder'] ?: '/') . '“ ist erreichbar und beschreibbar.' . $note]; } /** Wird alle ~5 Minuten vom Plugin-Takt aufgerufen: kopiert neue Backups des Servers auf die NAS und startet ein eigenes Backup, wenn es laut Zeitplan fällig ist. */ function nasbk_tick(PDO $pdo): void { $cfg = nasbk_config(); if (!$cfg['enabled'] || nasbk_config_error($cfg)) return; nasbk_sync_local($pdo, false); if (!$cfg['own_schedule']) return; $interval = nasbk_intervals()[$cfg['interval']][1]; $now = time(); $lastOk = strtotime((string) plugin_setting(NASBK, 'last_ok_at', '')) ?: 0; $lastRun = strtotime((string) plugin_setting(NASBK, 'last_run', '')) ?: 0; if ($now - $lastOk < $interval - min(3600, intdiv($interval, 10))) return; // noch nicht fällig if ($interval >= 86400 && $lastOk > 0 && date('H:i') < $cfg['time']) return; // täglich/wöchentlich/monatlich erst ab der eingestellten Uhrzeit if ($now - $lastRun < 3600) return; // nach einem Fehler frühestens nach einer Stunde erneut if (function_exists('pseudo_cron_try_claim') && !pseudo_cron_try_claim($pdo, 'plugin_nas_backup_lock', 600)) return; nasbk_run($pdo, false); } // ─── Zusammenarbeit mit der Backup-Seite ───────────────────────────────────── /** Dateiname eines Backups, das sich sicher verwenden lässt (kein Pfad, nur .zip im Namensschema). */ function nasbk_valid_name(string $name): bool { return $name === basename($name) && preg_match(nasbk_name_pattern(), $name) === 1; } /** Ordner mit den lokalen Backups von DeskHub. */ function nasbk_local_dir(): string { // Nur im Testbetrieb (DESKHUB_TESTING=1) darf ein anderer Ordner gesetzt werden if (getenv('DESKHUB_TESTING') === '1' && getenv('DESKHUB_BACKUP_DIR')) return rtrim((string) getenv('DESKHUB_BACKUP_DIR'), '/\\'); return nasbk_root() . '/backups'; } /** Pfad einer lokalen Sicherung oder null. */ function nasbk_local_path(string $name): ?string { if (!nasbk_valid_name($name)) return null; $dir = realpath(nasbk_local_dir()); $p = $dir ? $dir . '/' . $name : null; return $p && is_file($p) ? $p : null; } /** Lädt eine lokale Sicherung auf die NAS, kontrolliert die Größe und räumt nach der Aufbewahrungsregel auf. @return array{ok:bool,message:string} */ function nasbk_push_file(array $cfg, string $path, string $name): array { @set_time_limit(0); if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err]; if ($err = nasbk_ensure_folder($cfg)) return ['ok' => false, 'message' => $err]; if ($err = nasbk_upload($cfg, $path, $name)) return ['ok' => false, 'message' => $err]; $rot = nasbk_rotate($cfg); nasbk_cache_clear(); return ['ok' => true, 'message' => $name . ' auf die NAS kopiert' . ($rot['deleted'] ? ' (' . count($rot['deleted']) . ' alte Sicherung(en) dort gelöscht)' : '') . '.']; } /** Zwischenspeicher der NAS-Liste (damit die Backup-Seite nicht bei jedem Aufruf die NAS befragt). */ function nasbk_cache_clear(): void { plugin_set_setting(NASBK, 'remote_cache', ''); } /** @return array{ok:bool,items:array,error:string,at:int} */ function nasbk_remote_cached(array $cfg): array { $c = json_decode((string) plugin_setting(NASBK, 'remote_cache', ''), true); if (is_array($c) && time() - (int) ($c['at'] ?? 0) < (!empty($c['ok']) ? 60 : 180)) return $c; $l = nasbk_list($cfg, 8); $l['at'] = time(); plugin_set_setting(NASBK, 'remote_cache', json_encode($l, JSON_UNESCAPED_UNICODE)); return $l; } /** Speichert die Einstellungen aus dem Formular. @return array{ok:bool,message:string} */ function nasbk_save_settings(array $post, array $staff = []): array { $host = trim((string) ($post['host'] ?? '')); $host = preg_replace('#^https?://#i', '', $host); $host = rtrim(explode('/', $host)[0], ':'); $https = !empty($post['https']); $port = (int) ($post['port'] ?? 0); if ($port < 1 || $port > 65535) $port = $https ? 443 : 80; $target = '/' . trim(trim((string) ($post['target'] ?? '')), '/') . '/'; $default = ($https && $port === 443) || (!$https && $port === 80); $err = nasbk_url_error(($https ? 'https' : 'http') . '://' . $host . ($default ? '' : ':' . $port)); if (!$err && empty($post['db']) && empty($post['files']) && !empty($post['own_schedule'])) $err = 'Für den eigenen Zeitplan bitte mindestens „Datenbank“ oder „Dateien“ auswählen.'; if ($err) return ['ok' => false, 'message' => $err]; $interval = (string) ($post['interval'] ?? 'daily'); $time = (string) ($post['time'] ?? '02:00'); plugin_set_setting(NASBK, 'enabled', !empty($post['enabled']) ? '1' : '0'); plugin_set_setting(NASBK, 'host', $host); plugin_set_setting(NASBK, 'port', (string) $port); plugin_set_setting(NASBK, 'https', $https ? '1' : '0'); plugin_set_setting(NASBK, 'url', ''); // alte Einstellung durch die neuen Felder ersetzt plugin_set_setting(NASBK, 'folder', ''); plugin_set_setting(NASBK, 'target', $target); plugin_set_setting(NASBK, 'user', trim((string) ($post['user'] ?? ''))); $pw = (string) ($post['password'] ?? ''); if ($pw !== '') plugin_set_setting(NASBK, 'password', encrypt_secret($pw)); // leer = gespeichertes Passwort behalten plugin_set_setting(NASBK, 'verify_tls', !empty($post['verify_tls']) ? '1' : '0'); plugin_set_setting(NASBK, 'mirror', !empty($post['mirror']) ? '1' : '0'); plugin_set_setting(NASBK, 'own_schedule', !empty($post['own_schedule']) ? '1' : '0'); plugin_set_setting(NASBK, 'db', !empty($post['db']) ? '1' : '0'); plugin_set_setting(NASBK, 'files', !empty($post['files']) ? '1' : '0'); plugin_set_setting(NASBK, 'scope', ($post['scope'] ?? 'important') === 'full' ? 'full' : 'important'); plugin_set_setting(NASBK, 'excludes', mb_substr(trim((string) ($post['excludes'] ?? '')), 0, 4000)); plugin_set_setting(NASBK, 'interval', isset(nasbk_intervals()[$interval]) ? $interval : 'daily'); plugin_set_setting(NASBK, 'time', preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) ? $time : '02:00'); plugin_set_setting(NASBK, 'keep_days', (string) max(0, min(3650, (int) ($post['keep_days'] ?? 0)))); plugin_set_setting(NASBK, 'keep', (string) max(0, min(365, (int) ($post['keep'] ?? 14)))); nasbk_cache_clear(); return ['ok' => true, 'message' => 'Einstellungen gespeichert.']; } /** Holt eine Sicherung von der NAS in den Backup-Ordner und prüft sie. @return array{ok:bool,message:string} */ function nasbk_fetch(array $cfg, string $name): array { @set_time_limit(0); if (!nasbk_valid_name($name)) return ['ok' => false, 'message' => 'Ungültiger Dateiname.']; if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err]; $dir = realpath(nasbk_local_dir()); if (!$dir || !is_writable($dir)) return ['ok' => false, 'message' => 'Der Ordner backups auf dem Server ist nicht beschreibbar.']; $target = $dir . '/' . $name; if (is_file($target)) return ['ok' => false, 'message' => 'Diese Sicherung liegt schon auf dem Server.']; $tmp = $dir . '/.nas-' . bin2hex(random_bytes(4)) . '.part'; $r = nasbk_request($cfg, 'GET', nasbk_remote_url($cfg, $name), ['save_to' => $tmp, 'timeout' => 3600]); if ($r['code'] !== 200) { @unlink($tmp); return ['ok' => false, 'message' => nasbk_explain($r, 'Herunterladen')]; } $expected = nasbk_remote_size($cfg, nasbk_remote_url($cfg, $name)); if ($expected !== null && filesize($tmp) !== $expected) { @unlink($tmp); return ['ok' => false, 'message' => 'Herunterladen: Die Datei ist unvollständig angekommen. Bitte erneut versuchen.']; } if (!function_exists('backup_verify')) require_once nasbk_root() . '/includes/backup_restore.php'; $check = backup_verify($tmp); if (empty($check['ok'])) { @unlink($tmp); return ['ok' => false, 'message' => 'Die Sicherung von der NAS ist nicht in Ordnung: ' . implode(' ', (array) ($check['errors'] ?? []))]; } if (!@rename($tmp, $target)) { @unlink($tmp); return ['ok' => false, 'message' => 'Die Datei konnte nicht in den Backup-Ordner verschoben werden.']; } if (function_exists('backup_verify_and_record')) backup_verify_and_record($target); return ['ok' => true, 'message' => $name . ' von der NAS geholt und geprüft. Du kannst sie jetzt in der Liste wiederherstellen.']; } // ─── Kopieren der Backups dieses Servers ───────────────────────────────────── /** * Kopiert Backups aus dem Backup-Ordner des Servers (die DeskHub selbst erstellt hat: Zeitplan der Seite „Backup“, manuell, vor Updates) * auf die NAS. Nur Backups, die nicht als defekt geprüft wurden; ohne Prüfergebnis erst nach 20 Minuten (die automatische Prüfung * läuft alle 10 Minuten). Betrachtet werden die neuesten Backups im Rahmen der Aufbewahrung (Anzahl/Alter), damit nicht alte * Sicherungen hochgeladen und gleich wieder gelöscht werden. * * @return array{ok:bool,done:int,message:string} */ function nasbk_sync_local(PDO $pdo, bool $manual = false, int $max = 2): array { $cfg = nasbk_config(); if (!$manual && (!$cfg['enabled'] || !$cfg['mirror'])) return ['ok' => true, 'done' => 0, 'message' => '']; if ($e = nasbk_config_error($cfg)) return ['ok' => false, 'done' => 0, 'message' => $e]; if (!$manual) { $failAt = strtotime((string) plugin_setting(NASBK, 'sync_fail_at', '')) ?: 0; if (time() - $failAt < 3600) return ['ok' => true, 'done' => 0, 'message' => '']; // nach einem Fehler frühestens nach einer Stunde erneut if (function_exists('pseudo_cron_try_claim') && !pseudo_cron_try_claim($pdo, 'plugin_nas_backup_sync', 300)) return ['ok' => true, 'done' => 0, 'message' => '']; } @set_time_limit(0); $dir = realpath(nasbk_local_dir()); if (!$dir) return ['ok' => true, 'done' => 0, 'message' => '']; $files = []; foreach (glob($dir . '/backup_*.zip') ?: [] as $p) if (nasbk_valid_name(basename($p))) $files[$p] = filemtime($p); arsort($files); $limit = $cfg['keep'] > 0 ? $cfg['keep'] : 10; $files = array_slice($files, 0, $limit, true); if ($cfg['keep_days'] > 0) $files = array_filter($files, fn($t) => $t >= strtotime("-{$cfg['keep_days']} days")); if (!$files) return ['ok' => true, 'done' => 0, 'message' => '']; $remote = nasbk_list($cfg, 20); if (!$remote['ok']) return nasbk_sync_fail($remote['error'], $manual); $have = []; foreach ($remote['items'] as $it) $have[$it['name']] = $it['size']; $checks = json_decode((string) get_setting('backup_check_map', ''), true); $checks = is_array($checks) ? $checks : []; $done = 0; $skippedBad = 0; foreach ($files as $path => $mtime) { $name = basename($path); if (isset($have[$name]) && $have[$name] === filesize($path)) continue; // liegt schon vollständig auf der NAS $chk = $checks[$name] ?? null; if ($chk && empty($chk['ok'])) { $skippedBad++; continue; } // defekte Backups nicht kopieren if (!$chk && !$manual && time() - $mtime < 1200) continue; // noch nicht geprüft: kurz warten if (!$manual && $done >= $max) break; $r = nasbk_push_file($cfg, $path, $name); if (!$r['ok']) return nasbk_sync_fail($name . ': ' . $r['message'], $manual); nasbk_record(true, $r['message'], $manual); $done++; } if ($done > 0) nasbk_cache_clear(); $msg = $done . ' Sicherung(en) auf die NAS kopiert' . ($skippedBad ? '; ' . $skippedBad . ' defekte(s) Backup(s) übersprungen' : '') . '.'; return ['ok' => true, 'done' => $done, 'message' => $msg]; } /** Fehler beim Kopieren festhalten (E-Mail an die Admin-Adresse höchstens einmal am Tag) und weitere Versuche für eine Stunde aussetzen. */ function nasbk_sync_fail(string $message, bool $manual): array { plugin_set_setting(NASBK, 'sync_fail_at', date('Y-m-d H:i:s')); nasbk_record(false, 'Kopie auf die NAS fehlgeschlagen: ' . $message, $manual); return ['ok' => false, 'done' => 0, 'message' => $message]; } /** Adresse der Plugin-Seite (Reiter unter „Backup“). */ function nasbk_page_url(): string { return '/staff/plugin.php?p=' . NASBK . '&page=einstellungen'; } /** Kleines Formular, das eine Aktion dieser Seite auslöst ($target: andere Adresse, wenn es auf der Seite „Backup“ steht). */ function nasbk_action_form(string $do, string $label, array $extra = [], string $confirm = '', string $class = 'secondary', string $style = '', string $target = ''): string { $h = '
' . csrf_field() . ''; foreach ($extra as $k => $v) $h .= ''; return $h . '
'; } /** Aktionen der Plugin-Seite. @return array{ok:bool,message:string} */ function nasbk_page_action(string $do, array $post, array $staff = []): array { $cfg = nasbk_config(); $name = (string) ($post['file'] ?? ''); switch ($do) { case 'save': return nasbk_save_settings($post, $staff); case 'test': return nasbk_test($cfg); case 'run': if (nasbk_running()) return ['ok' => false, 'message' => 'Es läuft bereits ein Backup. Bitte kurz warten.']; nasbk_run_background(get_db()); return ['ok' => true, 'message' => 'Das eigene Backup läuft jetzt im Hintergrund. Das Ergebnis steht oben im Status; die Seite lädt sich von selbst neu.']; case 'sync': nasbk_cache_clear(); $r = nasbk_sync_local(get_db(), true); return ['ok' => $r['ok'], 'message' => $r['message']]; case 'upload': $path = nasbk_local_path($name); if ($path === null) return ['ok' => false, 'message' => 'Backup nicht gefunden.']; $r = nasbk_push_file($cfg, $path, $name); nasbk_record($r['ok'], $r['message'], true); return $r; case 'fetch': return nasbk_fetch($cfg, $name); case 'delete_remote': if (!nasbk_valid_name($name) || nasbk_config_error($cfg)) return ['ok' => false, 'message' => 'Ungültige Anfrage.']; $r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]); nasbk_cache_clear(); return in_array($r['code'], [200, 204], true) ? ['ok' => true, 'message' => $name . ' von der NAS gelöscht.'] : ['ok' => false, 'message' => nasbk_explain($r, 'Löschen')]; } return ['ok' => false, 'message' => 'Unbekannte Aktion.']; } /** * Ergänzt die Liste „Vorhandene Backups“ auf der Seite „Backup“ (Filter backup.list.rows): Marke „auf NAS“ / „nur hier“ bei den Backups des * Servers, Knopf „Auf NAS“ und Sicherungen, die nur auf der NAS liegen (mit „Auf Server holen“). Die Knöpfe senden an die Seite des Plugins. */ function nasbk_list_rows(array $rows, array $ctx = []): array { $cfg = nasbk_config(); if (!$cfg['enabled'] || nasbk_config_error($cfg) !== null) return $rows; $remote = nasbk_remote_cached($cfg); $url = nasbk_page_url(); $byName = []; if ($remote['ok']) foreach ($remote['items'] as $it) $byName[$it['name']] = $it; foreach ($rows as &$r) { if (($r['source'] ?? 'local') !== 'local' || !nasbk_valid_name((string) $r['name'])) continue; if (!$remote['ok']) { $r['badges'][] = '☁ unbekannt'; continue; } $bytes = (int) ($r['bytes'] ?? round(((float) $r['size']) * 1048576)); if (isset($byName[$r['name']])) { $same = $byName[$r['name']]['size'] === $bytes; $r['badges'][] = $same ? '☁ auf NAS' : '☁ unvollständig'; if (!$same) $r['actions'] = nasbk_action_form('upload', '☁ Erneut auf NAS', ['file' => $r['name']], '', 'secondary', '', $url) . ($r['actions'] ?? ''); } else { $r['badges'][] = 'nur hier'; $r['actions'] = nasbk_action_form('upload', '☁ Auf NAS', ['file' => $r['name']], '', 'secondary', '', $url) . ($r['actions'] ?? ''); } } unset($r); if ($remote['ok']) { $localNames = array_column($rows, 'name'); foreach ($remote['items'] as $it) { if (in_array($it['name'], $localNames, true)) continue; $rows[] = [ 'name' => $it['name'], 'size' => round($it['size'] / 1048576, 2), 'bytes' => $it['size'], 'date' => strtotime($it['modified']) ?: time(), 'source' => 'extern', 'badges' => ['☁ nur auf NAS'], 'actions' => nasbk_action_form('fetch', '⬇ Auf Server holen', ['file' => $it['name']], 'Diese Sicherung von der NAS auf den Server laden? Danach kannst du sie hier wiederherstellen.', 'secondary', '', $url) . nasbk_action_form('delete_remote', '🗑', ['file' => $it['name']], 'Diese Sicherung von der NAS löschen?', 'secondary', 'border-color:var(--danger);color:var(--danger)', $url), ]; } } return $rows; }