1113 lines
66 KiB
PHP
1113 lines
66 KiB
PHP
<?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;
|
||
}
|