Files
deskhub/Plugins/plugins/nas-backup/lib.php
T
2026-10-25 17:47:48 +00:00

1113 lines
66 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Logik des NAS-Backup-Plugins (ohne Seiten-/Hook-Code, damit sie einzeln testbar ist).
*
* Ablauf: Backup mit dem Kern erzeugen (create_backup: Dateien + Datenbank-Abzug), prüfen, per WebDAV (PUT) hochladen,
* Größe auf dem Server kontrollieren, alte Sicherungen über die gewünschte Anzahl hinaus löschen, Ergebnis festhalten.
*/
const NASBK = 'nas-backup';
/** Kleinste Teilgröße in Bytes (Tests überschreiben sie vorher mit define()). */
if (!defined('NASBK_MIN_PART')) define('NASBK_MIN_PART', 10 * 1048576);
/** Zeitpläne: Schlüssel => [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://<nas>:5006/<Name des freigegebenen Ordners>.",
$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' => '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', '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<int,array{name:string,size:int,modified:string,parts:int}>,orphans:array<int,array{name:string,modified:string}>,error:string}
*/
function nasbk_list(array $cfg, int $timeout = 30): array
{
$body = '<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>';
$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' => '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/></d:prop></d:propfind>', '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', '<p>Das automatische Backup auf die NAS ist fehlgeschlagen:</p><p><strong>' . e($message) . '</strong></p><p>Details und Einstellungen: Adminportal → NAS-Backup.</p>');
}
}
}
/** 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 = '<form method="post" style="margin:0"' . ($target !== '' ? ' action="' . e($target) . '"' : '') . ($confirm !== '' ? ' onsubmit="return confirm(' . e(json_encode($confirm, JSON_UNESCAPED_UNICODE)) . ')"' : '') . '>' . csrf_field()
. '<input type="hidden" name="do" value="' . e($do) . '">';
foreach ($extra as $k => $v) $h .= '<input type="hidden" name="' . e($k) . '" value="' . e($v) . '">';
return $h . '<button type="submit" class="btn ' . e($class) . ' bk-btn" style="padding:4px 9px;font-size:.76rem;white-space:nowrap' . ($style !== '' ? ';' . e($style) : '') . '">' . e($label) . '</button></form>';
}
/** 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'][] = '<span class="bk-badge" title="' . e('Die NAS ließ sich nicht abfragen: ' . $remote['error']) . '">☁ unbekannt</span>'; continue; }
$bytes = (int) ($r['bytes'] ?? round(((float) $r['size']) * 1048576));
if (isset($byName[$r['name']])) {
$same = $byName[$r['name']]['size'] === $bytes;
$r['badges'][] = $same ? '<span class="bk-badge ok" title="Diese Sicherung liegt vollständig auf der NAS">☁ auf NAS</span>' : '<span class="bk-badge bad" title="Die Datei auf der NAS hat eine andere Größe">☁ unvollständig</span>';
if (!$same) $r['actions'] = nasbk_action_form('upload', '☁ Erneut auf NAS', ['file' => $r['name']], '', 'secondary', '', $url) . ($r['actions'] ?? '');
} else {
$r['badges'][] = '<span class="bk-badge" title="Noch nicht auf der NAS (wird automatisch kopiert, sobald das Backup geprüft ist)">nur hier</span>';
$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' => ['<span class="bk-badge ok" title="Liegt nur auf der NAS">☁ nur auf NAS</span>'],
'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;
}