[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', // Große Backups in Teilen hochladen: automatisch (erst ganz, bei Fehler 413/Zeitüberschreitung in Teilen), immer oder nie 'split' => in_array($splitMode = (string) plugin_setting(NASBK, 'split', 'auto'), ['auto', 'always', 'never'], true) ? $splitMode : 'auto', 'part_mb' => max(10, min(4000, (int) plugin_setting(NASBK, 'part_mb', '200'))), 'part_bytes' => max(10, min(4000, (int) plugin_setting(NASBK, 'part_mb', '200'))) * 1048576, ]; } /** 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'])) { clearstatcache(true, $opt['file']); // dieselbe Zwischendatei wird für mehrere Teile neu beschrieben: sonst gilt die alte Größe $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['stream'])) { // Ein Abschnitt einer großen Datei direkt hochladen, ohne ihn vorher in eine Zwischendatei zu kopieren (spart bei großen Backups viel Plattenzugriff) $st = $opt['stream']; // ['handle' => Quelle, 'offset' => Beginn, 'length' => Länge, 'hash' => HashContext|null] $remaining = (int) $st['length']; fseek($st['handle'], (int) $st['offset']); $set[CURLOPT_UPLOAD] = true; $set[CURLOPT_INFILESIZE] = $remaining; $set[CURLOPT_READFUNCTION] = function ($curl, $fd, $length) use ($st, &$remaining) { if ($remaining <= 0) return ''; $data = fread($st['handle'], min($length, $remaining, 1048576)); if ($data === false || $data === '') return ''; $remaining -= strlen($data); if (!empty($st['hash'])) hash_update($st['hash'], $data); return $data; }; $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).", $c === 413 => "{$what}: Die Datei ist größer, als der Server oder ein Proxy davor annimmt (HTTP 413). Häufige Ursachen: ein Reverse-Proxy (bei nginx „client_max_body_size“ erhöhen, bei der Synology unter Anmeldeportal → Erweitert → Reverse-Proxy den Header-/Größen-Grenzwert prüfen) oder Cloudflare (im Gratis-Tarif höchstens 100 MB je Upload; die NAS-Adresse dann ohne Cloudflare-Proxy, also „nur DNS“, erreichbar machen). Alternativ das Backup verkleinern: in den Einstellungen nur die Datenbank oder die „Wichtigsten Dateien“ sichern.", in_array($c, [408, 502, 504, 524], true) => "{$what}: Zeitüberschreitung beim Server oder Proxy vor der NAS (HTTP {$c}). Bei großen Dateien muss dort das Zeitlimit höher sein (bei Cloudflare gilt für Uploads zusätzlich eine Grenze von 100 MB im Gratis-Tarif).", 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. Große Backups liegen in Teilen auf der NAS (name.zip.part001, .part002 … und zuletzt eine kleine Datei * name.zip.parts, die den Satz als vollständig kennzeichnet). Diese Sätze erscheinen hier als EIN Backup mit der Gesamtgröße und `parts` = Anzahl. * Teile ohne Abschlussdatei (ein Upload, der abgebrochen wurde) stehen unter `orphans`. * * @return array{ok:bool,items:array,orphans: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' => [], 'orphans' => [], 'error' => '']; if ($r['code'] !== 207) return ['ok' => false, 'items' => [], 'orphans' => [], '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' => [], 'orphans' => [], 'error' => 'Die Antwort der NAS ließ sich nicht lesen.']; $xml->registerXPathNamespace('d', 'DAV:'); $base = '(backup_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_[a-z0-9]{3})?\.zip)'; $singles = $parts = $markers = []; foreach ($xml->xpath('//d:response') ?: [] as $resp) { $resp->registerXPathNamespace('d', 'DAV:'); $href = (string) ($resp->xpath('d:href')[0] ?? ''); $name = basename(rawurldecode(rtrim($href, '/'))); $size = (int) ($resp->xpath('.//d:getcontentlength')[0] ?? 0); $mod = (string) ($resp->xpath('.//d:getlastmodified')[0] ?? ''); if (preg_match("/^{$base}$/", $name)) $singles[$name] = ['name' => $name, 'size' => $size, 'modified' => $mod, 'parts' => 0]; elseif (preg_match("/^{$base}\.part(\d{3})$/", $name, $m)) $parts[$m[1]][(int) $m[2]] = ['size' => $size, 'modified' => $mod, 'file' => $name]; elseif (preg_match("/^{$base}\.parts$/", $name, $m)) $markers[$m[1]] = $mod; } $items = array_values($singles); $orphans = []; foreach ($parts as $logical => $set) { ksort($set); $complete = isset($markers[$logical]) && !isset($singles[$logical]) && array_keys($set) === range(1, count($set)); if ($complete) { $items[] = ['name' => $logical, 'size' => array_sum(array_column($set, 'size')), 'modified' => $markers[$logical], 'parts' => count($set)]; } else { foreach ($set as $p) $orphans[] = ['name' => $p['file'], 'modified' => $p['modified']]; if (isset($markers[$logical])) $orphans[] = ['name' => $logical . '.parts', 'modified' => $markers[$logical]]; } } usort($items, fn($a, $b) => strcmp($b['name'], $a['name'])); // Namen enthalten das Datum: neueste zuerst return ['ok' => true, 'items' => $items, 'orphans' => $orphans, '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; } /** Name einer Teil-Datei auf der NAS: backup_….zip.part001 */ function nasbk_part_name(string $name, int $n): string { return sprintf('%s.part%03d', $name, $n); } /** * Lädt die Datei hoch und prüft die Größe. Gibt eine Fehlermeldung oder null zurück. * * Große Dateien scheitern oft an einer Größengrenze davor (Reverse-Proxy, Cloudflare: 100 MB). Deshalb gibt es zwei Wege: * - ganz (eine Datei, bequem zum Herunterladen von Hand) und * - in Teilen (je höchstens „Teilgröße“, danach eine kleine Abschlussdatei: erst damit gilt das Backup als vollständig). * Einstellung „automatisch“: erst ganz; schlägt das mit 413/Zeitüberschreitung/Verbindungsabbruch fehl, geht es in Teilen weiter und * die Größe wird gemerkt (ab dieser Größe gleich in Teilen, ohne die Datei noch einmal vergeblich zu senden). */ function nasbk_upload(array $cfg, string $localPath, string $name): ?string { $size = (int) filesize($localPath); $partBytes = (int) ($cfg['part_bytes'] ?? 200 * 1048576); $partLimit = (int) plugin_setting(NASBK, 'part_limit', '0'); // gemerkt: größere Teile lehnte der Server ab if ($partLimit > 0) $partBytes = min($partBytes, $partLimit); $mode = (string) ($cfg['split'] ?? 'auto'); $learned = (int) plugin_setting(NASBK, 'single_limit', '0'); // gemerkt: ab dieser Größe scheiterte der Upload in einem Stück $canSplit = $mode !== 'never' && ($size > $partBytes || ($mode === 'auto' && $learned > 0 && $size >= $learned)); if ($canSplit && ($mode === 'always' || ($learned > 0 && $size >= $learned))) return nasbk_upload_big($cfg, $localPath, $name, min($partBytes, max(NASBK_MIN_PART, intdiv($size, 2)))); $res = nasbk_upload_single($cfg, $localPath, $name); if ($res['error'] === null) return null; // Scheitert die ganze Datei an einer Größengrenze, geht es in Teilen weiter (auch wenn die Datei kleiner als die eingestellte Teilgröße ist) if ($mode !== 'never' && $size > NASBK_MIN_PART && in_array($res['code'], [0, 408, 413, 502, 504, 524], true)) { plugin_set_setting(NASBK, 'single_limit', (string) ($learned > 0 ? min($learned, $size) : $size)); return nasbk_upload_big($cfg, $localPath, $name, min($partBytes, max(NASBK_MIN_PART, intdiv($size, 2)))); } return $res['error']; } /** Eine Datei in einem Stück hochladen. @return array{error:?string,code:int} */ function nasbk_upload_single(array $cfg, string $localPath, string $name): array { $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 ['error' => nasbk_explain($r, 'Hochladen (' . round($size / 1048576, 1) . ' MB)'), 'code' => $r['code']]; $remote = nasbk_remote_size($cfg, $url); if ($remote !== null && $remote !== $size) { nasbk_request($cfg, 'DELETE', $url, ['timeout' => 30]); return ['error' => "Hochladen: Die Datei auf der NAS ist unvollständig ({$remote} statt {$size} Bytes) und wurde wieder gelöscht.", 'code' => 200]; } return ['error' => null, 'code' => $r['code']]; } /** * Große Datei in Stücken als EINE Datei hochladen (HTTP „Content-Range“: jedes Stück sagt, wohin es gehört; Synology DSM 7 kann das). * Auf der NAS liegt danach direkt die fertige ZIP, ohne Teile und ohne Zusammensetzen. * * Nach jedem Stück wird die Größe auf der NAS geprüft. Ignoriert ein Server „Content-Range“ stillschweigend (er würde die Datei bei jedem * Stück überschreiben und trotzdem „OK“ melden), fällt das auf und es wird als „nicht unterstützt“ gemeldet. Lehnt der Server ein Stück als * zu groß ab (HTTP 413), wird die Stückgröße halbiert und von vorn begonnen. Pro Stück bis zu 4 Versuche mit wachsender Wartezeit (3, 6, 12 s). * Bei einem Fehler wird die unvollständige Datei auf der NAS gelöscht. * * @return array{error:?string,code:int,unsupported:bool} */ function nasbk_upload_range(array $cfg, string $localPath, string $name, int $chunkBytes): array { @set_time_limit(0); $url = nasbk_remote_url($cfg, $name); clearstatcache(true, $localPath); $size = (int) filesize($localPath); $src = @fopen($localPath, 'rb'); if ($src === false) return ['error' => 'Hochladen in Stücken: Die Backup-Datei konnte nicht gelesen werden.', 'code' => 0, 'unsupported' => false]; $drop = fn() => nasbk_request($cfg, 'DELETE', $url, ['timeout' => 60]); $drop(); // Reste eines früheren Versuchs entfernen (sonst hängt die NAS „(2)“ an den Namen oder hängt Daten an) $chunk = max(NASBK_MIN_PART, $chunkBytes); while (true) { $total = max(1, (int) ceil($size / $chunk)); $halve = false; for ($i = 0; $i < $total; $i++) { $offset = $i * $chunk; $len = min($chunk, $size - $offset); $end = $offset + $len - 1; $ok = false; $last = null; $lastCode = 0; for ($try = 1; $try <= 4 && !$ok; $try++) { $r = nasbk_request($cfg, 'PUT', $url, [ 'stream' => ['handle' => $src, 'offset' => $offset, 'length' => $len, 'hash' => null], 'headers' => ["Content-Range: bytes {$offset}-{$end}/{$size}", 'Content-Type: application/octet-stream'], 'timeout' => max(300, (int) ceil($len / 1048576) * 20), ]); $lastCode = $r['code']; if (in_array($r['code'], [200, 201, 204, 206], true)) { $remote = nasbk_remote_size($cfg, $url); if ($remote === null || $remote === $offset + $len) { $ok = true; break; } // Die NAS hat „Content-Range“ nicht beachtet (Datei hat nicht die erwartete Größe) fclose($src); $drop(); return ['error' => 'Hochladen in Stücken: Die NAS beachtet „Content-Range“ nicht.', 'code' => $r['code'], 'unsupported' => true]; } if ($i === 0 && in_array($r['code'], [400, 405, 416, 501], true)) { // Server kennt Stück-Uploads nicht fclose($src); $drop(); return ['error' => nasbk_explain($r, 'Hochladen in Stücken'), 'code' => $r['code'], 'unsupported' => true]; } $last = nasbk_explain($r, "Hochladen von Stück " . ($i + 1) . " von {$total} (" . round($len / 1048576, 1) . ' MB)'); if ($r['code'] === 413 || in_array($r['code'], [401, 403, 507], true)) break; // wiederholen hilft nicht if ($try < 4) sleep(min(60, 3 * (2 ** ($try - 1)))); } if (!$ok) { if ($lastCode === 413 && $chunk > NASBK_MIN_PART) { $halve = true; break; } fclose($src); $drop(); return ['error' => (string) $last, 'code' => $lastCode, 'unsupported' => false]; } } if ($halve) { $chunk = max(NASBK_MIN_PART, intdiv($chunk, 2)); plugin_set_setting(NASBK, 'part_limit', (string) $chunk); // künftig gleich mit dieser Größe beginnen $drop(); continue; } fclose($src); return ['error' => null, 'code' => 201, 'unsupported' => false]; } } /** * Große Datei hochladen, die nicht in einem Stück geht: zuerst als eine Datei in Stücken (Content-Range), sonst als Teile mit Abschlussdatei. * Die Unterstützung der NAS wird gemerkt („range_support“: 1 = geht, 0 = geht nicht), damit nicht jedes Mal vergeblich probiert wird. */ function nasbk_upload_big(array $cfg, string $localPath, string $name, int $chunkBytes): ?string { if ((string) plugin_setting(NASBK, 'range_support', '') !== '0') { $r = nasbk_upload_range($cfg, $localPath, $name, $chunkBytes); if ($r['error'] === null) { plugin_set_setting(NASBK, 'range_support', '1'); return null; } if (!$r['unsupported']) return $r['error']; plugin_set_setting(NASBK, 'range_support', '0'); } return nasbk_upload_parts($cfg, $localPath, $name, $chunkBytes); } /** * Lädt die Datei in Teilen hoch. Jedes Teil wird direkt aus der Backup-Datei gesendet (keine Zwischenkopie), danach auf der NAS auf die * richtige Größe geprüft und bei Fehlern bis zu dreimal neu gesendet. Erst wenn alle Teile da sind, kommt die Abschlussdatei „name.zip.parts“ * (Größe, Prüfsumme SHA-256, Liste der Teile). Bei einem Fehler werden die schon hochgeladenen Teile entfernt. * * Lehnt der Server ein Teil als zu groß ab (HTTP 413), wird die Teilgröße halbiert (nicht unter 10 MB) und von vorn begonnen; die * funktionierende Größe wird gemerkt. So passt sich das Plugin einer unbekannten Grenze selbst an. */ function nasbk_upload_parts(array $cfg, string $localPath, string $name, int $partBytes): ?string { @set_time_limit(0); $min = NASBK_MIN_PART; $partBytes = max($min, $partBytes); while (true) { $res = nasbk_upload_parts_try($cfg, $localPath, $name, $partBytes); if ($res['error'] === null) return null; if ($res['code'] === 413 && $partBytes > $min) { $partBytes = max($min, intdiv($partBytes, 2)); plugin_set_setting(NASBK, 'part_limit', (string) $partBytes); // künftig gleich mit dieser Größe beginnen continue; } return $res['error']; } } /** Ein Durchgang mit fester Teilgröße. @return array{error:?string,code:int} */ function nasbk_upload_parts_try(array $cfg, string $localPath, string $name, int $partBytes): array { clearstatcache(true, $localPath); $size = (int) filesize($localPath); $count = max(1, (int) ceil($size / $partBytes)); $src = @fopen($localPath, 'rb'); if ($src === false) return ['error' => 'Hochladen in Teilen: Die Backup-Datei konnte nicht gelesen werden.', 'code' => 0]; $hash = hash_init('sha256'); $done = []; $fail = function (string $msg, int $code) use (&$done, $cfg, $name, $src): array { fclose($src); foreach ($done as $n) nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, nasbk_part_name($name, $n)), ['timeout' => 60]); return ['error' => $msg, 'code' => $code]; }; $manifest = ['name' => $name, 'size' => $size, 'part_size' => $partBytes, 'parts' => []]; for ($n = 1; $n <= $count; $n++) { $offset = ($n - 1) * $partBytes; $len = min($partBytes, $size - $offset); $url = nasbk_remote_url($cfg, nasbk_part_name($name, $n)); $ok = false; $last = null; $lastCode = 0; for ($try = 1; $try <= 3 && !$ok; $try++) { $ctx = hash_copy($hash); // Prüfsumme nur übernehmen, wenn dieser Versuch gelingt (ein Wiederholungsversuch liest das Teil neu) $r = nasbk_request($cfg, 'PUT', $url, ['stream' => ['handle' => $src, 'offset' => $offset, 'length' => $len, 'hash' => $ctx], 'timeout' => max(300, (int) ceil($len / 1048576) * 20)]); $lastCode = $r['code']; if (in_array($r['code'], [200, 201, 204], true)) { $remote = nasbk_remote_size($cfg, $url); if ($remote === null || $remote === $len) { $ok = true; $hash = $ctx; } else $last = "Teil {$n} ist unvollständig auf der NAS angekommen ({$remote} statt {$len} Bytes)."; } else { $last = nasbk_explain($r, "Hochladen von Teil {$n} von {$count} (" . round($len / 1048576, 1) . ' MB)'); if (in_array($r['code'], [413, 401, 403, 507], true)) break; // wiederholen hilft nicht } if (!$ok && $try < 3) sleep(2 * $try); } if (!$ok) return $fail((string) $last, $lastCode); $done[] = $n; $manifest['parts'][] = ['n' => $n, 'file' => nasbk_part_name($name, $n), 'size' => $len]; } fclose($src); $manifest['sha256'] = hash_final($hash); $manifest['created'] = date('c'); $tmp = tempnam(sys_get_temp_dir(), 'nasbkm'); file_put_contents($tmp, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $r = nasbk_request($cfg, 'PUT', nasbk_remote_url($cfg, $name . '.parts'), ['file' => $tmp, 'timeout' => 120]); @unlink($tmp); if (!in_array($r['code'], [200, 201, 204], true)) { foreach ($done as $n) nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, nasbk_part_name($name, $n)), ['timeout' => 60]); return ['error' => nasbk_explain($r, 'Abschlussdatei schreiben'), 'code' => $r['code']]; } return ['error' => null, 'code' => 201]; } /** Löscht eine Sicherung auf der NAS samt allen Teilen. $item stammt aus nasbk_list() (ohne Angabe wird die Liste geholt). */ function nasbk_delete_remote(array $cfg, string $name, ?array $item = null): bool { if ($item === null) { $l = nasbk_list($cfg); foreach ($l['items'] as $it) if ($it['name'] === $name) $item = $it; } $okCodes = [200, 204, 404]; $ok = true; if (!empty($item['parts'])) { // Zuerst die Abschlussdatei: Ab dann gilt die Sicherung nicht mehr als vollständig $r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name . '.parts'), ['timeout' => 60]); $ok = in_array($r['code'], $okCodes, true) && $ok; for ($n = 1; $n <= (int) $item['parts']; $n++) { $r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, nasbk_part_name($name, $n)), ['timeout' => 60]); $ok = in_array($r['code'], $okCodes, true) && $ok; } return $ok; } $r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]); return in_array($r['code'], $okCodes, true); } /** Löscht Sicherungen nach den beiden Regeln „älter als X Tage“ und „mehr als Y Stück“ (0 = Regel aus). Räumt außerdem abgebrochene Teil-Uploads auf. @return array{deleted:string[],error:string} */ function nasbk_rotate(array $cfg): array { $list = nasbk_list($cfg); if (!$list['ok']) return ['deleted' => [], 'error' => $list['error']]; // Teile ohne Abschlussdatei, die älter als ein Tag sind (ein laufender Upload bleibt unberührt) foreach ($list['orphans'] as $o) { $t = strtotime($o['modified']) ?: 0; if ($t > 0 && time() - $t > 86400) nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $o['name']), ['timeout' => 60]); } if ($cfg['keep'] <= 0 && $cfg['keep_days'] <= 0) return ['deleted' => [], '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']] = $it; } } if ($cfg['keep'] > 0) foreach (array_slice($items, $cfg['keep']) as $it) $drop[$it['name']] = $it; $deleted = []; foreach ($drop as $name => $it) { if (nasbk_delete_remote($cfg, $name, $it)) $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) // Ältere DeskHub-Kerne (vor 1.0.45) schließen den Ordner backups/ bei einem anderen Zielordner nicht aus: dann steckten alle alten Backups // noch einmal im neuen. Dort schließt das Plugin ihn selbst aus (ab 1.0.45 übernimmt das der Kern und behält die .htaccess). if (!defined('DESKHUB_VERSION') || version_compare((string) DESKHUB_VERSION, '1.0.45', '<')) $options['exclude'] = array_values(array_unique(array_merge((array) $options['exclude'], ['backups']))); // „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']; // Nur ein Backup gleichzeitig (ab DeskHub 1.0.45): läuft gerade das automatische oder ein manuelles Backup, wird dieses hier später erneut versucht $locked = true; if (function_exists('backup_lock_acquire')) { $locked = backup_lock_acquire(get_db()); if (!$locked) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => 'Es läuft gerade ein anderes Backup. Das NAS-Backup wird später erneut versucht.']; } } $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 ($locked && function_exists('backup_lock_release')) backup_lock_release(get_db()); } 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]; } $t0 = microtime(true); $arch = nasbk_make_archive($cfg); $tArchive = microtime(true) - $t0; 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]; } $t1 = microtime(true); if ($err = nasbk_upload($cfg, $arch['path'], $arch['filename'])) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; } $tUpload = microtime(true) - $t1; $rot = nasbk_rotate($cfg); $msg = $arch['filename'] . ' (' . $arch['size'] . ' MB, ' . ($cfg['mode'] === 'database' ? 'nur Datenbank' : 'komplett') . ') auf die NAS hochgeladen' . ' – Backup erstellen: ' . nasbk_duration($tArchive) . ', Hochladen: ' . nasbk_speed((int) round($arch['size'] * 1048576), $tUpload) . ($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']); } } /** „2 Min 5 s“ bzw. „1 Std 3 Min“ für die Statusanzeige. */ function nasbk_duration(float $sec): string { $sec = (int) round($sec); if ($sec < 60) return $sec . ' s'; if ($sec < 3600) return intdiv($sec, 60) . ' Min ' . ($sec % 60) . ' s'; return intdiv($sec, 3600) . ' Std ' . intdiv($sec % 3600, 60) . ' Min'; } /** „4550 MB in 1 Std 2 Min (1,2 MB/s)“: zeigt, wohin die Zeit geht. */ function nasbk_speed(int $bytes, float $sec): string { $mb = $bytes / 1048576; return round($mb) . ' MB in ' . nasbk_duration($sec) . ($sec > 0 ? ' (' . number_format($mb / $sec, 1, ',', '') . ' MB/s)' : ''); } /** 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 { nasbk_defer(fn() => nasbk_run($pdo, true), 'Backup'); } /** * Führt eine lange Aufgabe (Backup, Upload, Download) nach dem Senden der Antwort aus. Wichtig: Die Sitzungssperre wird vorher freigegeben, * sonst wartet jede weitere Seite desselben Mitarbeiters, bis die Aufgabe fertig ist. Ohne fastcgi_finish_request wird die Ausgabe wenigstens * vollständig geschickt. Das Ergebnis steht danach im Status der Plugin-Seite. * * @return array{ok:bool,message:string} Sofortige Antwort für die Seite */ function nasbk_defer(callable $job, string $what): array { if (nasbk_running()) return ['ok' => false, 'message' => 'Es läuft bereits ein Vorgang (Backup, Upload oder Download). Bitte kurz warten.']; plugin_set_setting(NASBK, 'running_since', (string) time()); register_shutdown_function(function () use ($job, $what) { if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); } else { while (ob_get_level() > 0) { @ob_end_flush(); } @flush(); } if (session_status() === PHP_SESSION_ACTIVE) @session_write_close(); @ignore_user_abort(true); @set_time_limit(0); try { $job(); } catch (Throwable $e) { nasbk_record(false, $what . ' abgebrochen: ' . $e->getMessage(), true); } finally { plugin_set_setting(NASBK, 'running_since', '0'); } }); return ['ok' => true, 'message' => $what . ' läuft jetzt im Hintergrund. Das Ergebnis steht oben im Status; die Seite lädt sich von selbst neu.']; } /** 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]; $t1 = microtime(true); if ($err = nasbk_upload($cfg, $path, $name)) return ['ok' => false, 'message' => $err]; $took = microtime(true) - $t1; $rot = nasbk_rotate($cfg); nasbk_cache_clear(); return ['ok' => true, 'message' => $name . ' auf die NAS kopiert (' . nasbk_speed((int) filesize($path), $took) . ')' . ($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)))); plugin_set_setting(NASBK, 'split', in_array($post['split'] ?? 'auto', ['auto', 'always', 'never'], true) ? (string) $post['split'] : 'auto'); plugin_set_setting(NASBK, 'part_mb', (string) max(10, min(4000, (int) ($post['part_mb'] ?? 200)))); plugin_set_setting(NASBK, 'part_limit', '0'); plugin_set_setting(NASBK, 'range_support', ''); // neu ausprobieren, ob die NAS Stück-Uploads kann plugin_set_setting(NASBK, 'single_limit', '0'); // gemerkte Größengrenze zurücksetzen: nach einer Änderung (z. B. am Proxy) neu ausprobieren nasbk_cache_clear(); return ['ok' => true, 'message' => 'Einstellungen gespeichert.']; } /** * Lädt eine in Teilen abgelegte Sicherung herunter und setzt sie in $target zusammen. Geprüft werden Abschlussdatei, Größe jedes Teils, * Gesamtgröße und SHA-256 des Ganzen. Gibt eine Fehlermeldung oder null zurück. */ function nasbk_download_parts(array $cfg, string $name, string $target): ?string { @set_time_limit(0); $m = nasbk_request($cfg, 'GET', nasbk_remote_url($cfg, $name . '.parts'), ['timeout' => 60]); if ($m['code'] !== 200) return nasbk_explain($m, 'Abschlussdatei lesen'); $man = json_decode($m['body'], true); if (!is_array($man) || ($man['name'] ?? '') !== $name || empty($man['parts']) || !is_array($man['parts']) || !preg_match('/^[a-f0-9]{64}$/', (string) ($man['sha256'] ?? ''))) { return 'Herunterladen: Die Abschlussdatei der Sicherung ist beschädigt.'; } $out = @fopen($target, 'wb'); if ($out === false) return 'Herunterladen: Die Zieldatei konnte nicht angelegt werden.'; $piece = $target . '.teil'; $total = 0; foreach ($man['parts'] as $p) { $n = (int) ($p['n'] ?? 0); $want = (int) ($p['size'] ?? -1); $pn = nasbk_part_name($name, $n); $r = nasbk_request($cfg, 'GET', nasbk_remote_url($cfg, $pn), ['save_to' => $piece, 'timeout' => 3600]); if ($r['code'] !== 200) { fclose($out); @unlink($piece); return nasbk_explain($r, "Herunterladen von Teil {$n}"); } if ($want < 0 || filesize($piece) !== $want) { fclose($out); @unlink($piece); return "Herunterladen: Teil {$n} ist unvollständig angekommen. Bitte erneut versuchen."; } $in = fopen($piece, 'rb'); stream_copy_to_stream($in, $out); fclose($in); @unlink($piece); $total += $want; } fclose($out); clearstatcache(true, $target); if ($total !== (int) ($man['size'] ?? -1) || filesize($target) !== $total) return 'Herunterladen: Die zusammengesetzte Datei hat nicht die erwartete Größe.'; if (!hash_equals((string) $man['sha256'], (string) hash_file('sha256', $target))) return 'Herunterladen: Die Prüfsumme der zusammengesetzten Datei stimmt nicht. Bitte erneut versuchen.'; return null; } /** 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'; // Liegt die Sicherung in Teilen auf der NAS? Dann werden die Teile nacheinander geholt und zusammengesetzt. $item = null; $l = nasbk_list($cfg, 30); if ($l['ok']) foreach ($l['items'] as $it) if ($it['name'] === $name) $item = $it; if ($item && !empty($item['parts'])) { $err = nasbk_download_parts($cfg, $name, $tmp); if ($err !== null) { @unlink($tmp); return ['ok' => false, 'message' => $err]; } } else { $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': return nasbk_defer(fn() => nasbk_run(get_db(), true), 'Das eigene Backup'); case 'sync': return nasbk_defer(function () { nasbk_cache_clear(); $r = nasbk_sync_local(get_db(), true); if ($r['ok'] && $r['done'] === 0) nasbk_record(true, $r['message'] !== '' ? $r['message'] : 'Es gab nichts zu kopieren.', true); }, 'Das Kopieren der Backups'); case 'upload': $path = nasbk_local_path($name); if ($path === null) return ['ok' => false, 'message' => 'Backup nicht gefunden.']; return nasbk_defer(function () use ($cfg, $path, $name) { $r = nasbk_push_file($cfg, $path, $name); nasbk_record($r['ok'], $r['message'], true); }, 'Das Hochladen von ' . $name); case 'fetch': if (!nasbk_valid_name($name)) return ['ok' => false, 'message' => 'Ungültiger Dateiname.']; return nasbk_defer(function () use ($cfg, $name) { $r = nasbk_fetch($cfg, $name); nasbk_record($r['ok'], $r['message'], true); }, 'Das Herunterladen von ' . $name); case 'delete_remote': if (!nasbk_valid_name($name) || nasbk_config_error($cfg)) return ['ok' => false, 'message' => 'Ungültige Anfrage.']; $gone = nasbk_delete_remote($cfg, $name); nasbk_cache_clear(); return $gone ? ['ok' => true, 'message' => $name . ' von der NAS gelöscht.'] : ['ok' => false, 'message' => 'Löschen: Die Sicherung (oder einzelne Teile) ließ sich auf der NAS nicht löschen. Prüfe, ob der Benutzer dort Löschrechte hat.']; } 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; }