Upload via GUI (97 Dateien)

This commit is contained in:
2026-10-25 17:47:48 +00:00
parent 57648169aa
commit a4736e39ab
5 changed files with 3306 additions and 1190 deletions
+428 -45
View File
@@ -7,6 +7,8 @@
*/
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
@@ -82,6 +84,10 @@ function nasbk_config(): array
'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. */
@@ -151,12 +157,29 @@ function nasbk_request(array $cfg, string $method, string $url, array $opt = [])
$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'];
}
@@ -187,6 +210,8 @@ function nasbk_explain(array $r, string $what): string
$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}).",
};
}
@@ -212,34 +237,51 @@ function nasbk_ensure_folder(array $cfg): ?string
}
return $exists() ? null : 'Ordner anlegen: Der Zielordner „' . $cfg['folder'] . '“ existiert nicht und konnte nicht angelegt werden. Lege ihn auf der NAS an oder prüfe den Pfad (er beginnt mit dem Namen des freigegebenen Ordners).';
}
/** Liest die Dateien im Ordner. @return array{ok:bool,items:array<int,array{name:string,size:int,modified:string}>,error:string} */
/**
* 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' => [], 'error' => ''];
if ($r['code'] !== 207) return ['ok' => false, 'items' => [], 'error' => nasbk_explain($r, 'Liste der Sicherungen')];
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' => [], 'error' => 'Die Antwort der NAS ließ sich nicht lesen.'];
if ($xml === false) return ['ok' => false, 'items' => [], 'orphans' => [], 'error' => 'Die Antwort der NAS ließ sich nicht lesen.'];
$xml->registerXPathNamespace('d', 'DAV:');
$items = [];
$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, '/')));
if ($name === '' || !preg_match(nasbk_name_pattern(), $name)) continue;
$items[] = [
'name' => $name,
'size' => (int) ($resp->xpath('.//d:getcontentlength')[0] ?? 0),
'modified' => (string) ($resp->xpath('.//d:getlastmodified')[0] ?? ''),
];
$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, 'error' => ''];
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
{
@@ -256,44 +298,276 @@ function nasbk_remote_size(array $cfg, string $url): ?int
return null;
}
/** Lädt die Datei hoch und prüft die Größe. Gibt eine Fehlermeldung oder null zurück. */
/** 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 nasbk_explain($r, 'Hochladen');
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 "Hochladen: Die Datei auf der NAS ist unvollständig ({$remote} statt {$size} Bytes) und wurde wieder gelöscht.";
return ['error' => "Hochladen: Die Datei auf der NAS ist unvollständig ({$remote} statt {$size} Bytes) und wurde wieder gelöscht.", 'code' => 200];
}
return null;
return ['error' => null, 'code' => $r['code']];
}
/** Löscht Sicherungen nach den beiden Regeln „älter als X Tage“ und „mehr als Y Stück“ (0 = Regel aus). @return array{deleted:string[],error:string} */
/**
* 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
{
if ($cfg['keep'] <= 0 && $cfg['keep_days'] <= 0) return ['deleted' => [], 'error' => ''];
$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']] = true;
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']] = true;
if ($cfg['keep'] > 0) foreach (array_slice($items, $cfg['keep']) as $it) $drop[$it['name']] = $it;
$deleted = [];
foreach (array_keys($drop) as $name) {
$r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]);
if (in_array($r['code'], [200, 204, 404], true)) $deleted[] = $name;
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
{
@@ -330,13 +604,23 @@ function nasbk_make_archive(array $cfg): array
$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']) {
@@ -380,13 +664,18 @@ function nasbk_run(PDO $pdo, bool $manual = false): array
$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);
@@ -396,6 +685,21 @@ function nasbk_run(PDO $pdo, bool $manual = false): array
}
}
/** „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
{
@@ -406,18 +710,39 @@ function nasbk_running(): bool
/** 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 ($pdo) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
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 {
nasbk_run($pdo, true);
$job();
} catch (Throwable $e) {
nasbk_record(false, 'Backup abgebrochen: ' . $e->getMessage(), true);
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
@@ -484,10 +809,12 @@ 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' . ($rot['deleted'] ? ' (' . count($rot['deleted']) . ' alte Sicherung(en) dort gelöscht)' : '') . '.'];
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). */
@@ -544,10 +871,52 @@ function nasbk_save_settings(array $post, array $staff = []): array
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
{
@@ -559,10 +928,19 @@ function nasbk_fetch(array $cfg, string $name): array
$target = $dir . '/' . $name;
if (is_file($target)) return ['ok' => false, 'message' => 'Diese Sicherung liegt schon auf dem Server.'];
$tmp = $dir . '/.nas-' . bin2hex(random_bytes(4)) . '.part';
$r = nasbk_request($cfg, 'GET', nasbk_remote_url($cfg, $name), ['save_to' => $tmp, 'timeout' => 3600]);
if ($r['code'] !== 200) { @unlink($tmp); return ['ok' => false, 'message' => nasbk_explain($r, 'Herunterladen')]; }
$expected = nasbk_remote_size($cfg, nasbk_remote_url($cfg, $name));
if ($expected !== null && filesize($tmp) !== $expected) { @unlink($tmp); return ['ok' => false, 'message' => 'Herunterladen: Die Datei ist unvollständig angekommen. Bitte erneut versuchen.']; }
// 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'] ?? []))]; }
@@ -662,26 +1040,31 @@ function nasbk_page_action(string $do, array $post, array $staff = []): array
case 'test':
return nasbk_test($cfg);
case 'run':
if (nasbk_running()) return ['ok' => false, 'message' => 'Es läuft bereits ein Backup. Bitte kurz warten.'];
nasbk_run_background(get_db());
return ['ok' => true, 'message' => 'Das eigene Backup läuft jetzt im Hintergrund. Das Ergebnis steht oben im Status; die Seite lädt sich von selbst neu.'];
return nasbk_defer(fn() => nasbk_run(get_db(), true), 'Das eigene Backup');
case 'sync':
nasbk_cache_clear();
$r = nasbk_sync_local(get_db(), true);
return ['ok' => $r['ok'], 'message' => $r['message']];
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.'];
$r = nasbk_push_file($cfg, $path, $name);
nasbk_record($r['ok'], $r['message'], true);
return $r;
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':
return nasbk_fetch($cfg, $name);
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.'];
$r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]);
$gone = nasbk_delete_remote($cfg, $name);
nasbk_cache_clear();
return in_array($r['code'], [200, 204], true) ? ['ok' => true, 'message' => $name . ' von der NAS gelöscht.'] : ['ok' => false, 'message' => nasbk_explain($r, 'Löschen')];
return $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.'];
}