Files
deskhub/Plugins/plugins/nas-backup/lib.php
T
2026-10-19 18:14:57 +00:00

730 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Logik des NAS-Backup-Plugins (ohne Seiten-/Hook-Code, damit sie einzeln testbar ist).
*
* Ablauf: Backup mit dem Kern erzeugen (create_backup: Dateien + Datenbank-Abzug), prüfen, per WebDAV (PUT) hochladen,
* Größe auf dem Server kontrollieren, alte Sicherungen über die gewünschte Anzahl hinaus löschen, Ergebnis festhalten.
*/
const NASBK = 'nas-backup';
/** 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',
];
}
/** Prüft die Adresse: https ist Pflicht, http nur für Geräte im eigenen Netz (private oder lokale Adresse). Gibt eine Fehlermeldung oder null zurück. */
/** Prüft die Adresse: https ist Pflicht, http nur für Geräte im eigenen Netz (private oder lokale Adresse). Gibt eine Fehlermeldung oder null zurück. */
function nasbk_url_error(string $url): ?string
{
if ($url === '') return 'Bitte die Adresse (Domain oder IP) des WebDAV-Servers eintragen.';
$p = parse_url($url);
if ($p === false || empty($p['host']) || empty($p['scheme'])) return 'Die Adresse ist ungültig (Beispiel: nas.meinedomain.de).';
if (isset($p['user']) || isset($p['pass']) || isset($p['query']) || isset($p['fragment'])) return 'Die Adresse darf keine Zugangsdaten, Parameter oder Anker enthalten. Benutzer und Passwort kommen in die eigenen Felder.';
$scheme = strtolower($p['scheme']);
if (!in_array($scheme, ['http', 'https'], true)) return 'Es sind nur Adressen mit https:// (oder im eigenen Netz http://) erlaubt.';
if (!preg_match('/^[A-Za-z0-9.\-]+$|^\[[0-9a-f:]+\]$/i', $p['host'])) return 'Die Adresse enthält ungültige Zeichen.';
if ($scheme === 'http') {
$host = trim($p['host'], '[]');
$local = $host === 'localhost' || (filter_var($host, FILTER_VALIDATE_IP) && !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE));
if (!$local) return 'Über das Internet ist nur HTTPS erlaubt, sonst würden Passwort und Backup unverschlüsselt übertragen. HTTP geht nur mit einer Adresse im eigenen Netz.';
}
return null;
}
/** Fehler der gesamten Einstellung oder null, wenn alles Nötige da ist. */
function nasbk_config_error(array $cfg): ?string
{
if ($e = nasbk_url_error($cfg['url'])) return $e;
if ($cfg['user'] === '' || $cfg['password'] === '') return 'Bitte Benutzer und Passwort des NAS eintragen.';
foreach (explode('/', $cfg['folder']) as $seg) {
if ($seg === '.' || $seg === '..') return 'Der Ordnername ist ungültig.';
}
return null;
}
/** Adresse eines Ordners/einer Datei auf dem Server (Ordnersegmente werden einzeln kodiert). */
function nasbk_remote_url(array $cfg, string $file = '', bool $withFolder = true): string
{
$url = $cfg['url'];
if ($withFolder && $cfg['folder'] !== '') {
$url .= '/' . implode('/', array_map('rawurlencode', explode('/', $cfg['folder'])));
}
return $file !== '' ? $url . '/' . rawurlencode($file) : $url;
}
/** Ein WebDAV-Aufruf. Optionen: body (Text), file (Pfad zum Hochladen), headers (Liste), timeout (Sekunden). */
function nasbk_request(array $cfg, string $method, string $url, array $opt = []): array
{
if (!function_exists('curl_init')) return ['code' => 0, 'body' => '', 'error' => 'Auf diesem Server fehlt die PHP-Erweiterung cURL.'];
$ch = curl_init($url);
$headers = $opt['headers'] ?? [];
$fh = null;
$set = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $cfg['user'] . ':' . $cfg['password'],
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_FOLLOWLOCATION => false, // niemals Zugangsdaten an eine Weiterleitung schicken
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => (int) ($opt['timeout'] ?? 60),
CURLOPT_SSL_VERIFYPEER => $cfg['verify_tls'],
CURLOPT_SSL_VERIFYHOST => $cfg['verify_tls'] ? 2 : 0,
CURLOPT_USERAGENT => 'DeskHub-NAS-Backup',
];
$out = null;
if (isset($opt['save_to'])) {
$out = fopen($opt['save_to'], 'wb');
if ($out === false) return ['code' => 0, 'body' => '', 'error' => 'Die Zieldatei konnte nicht angelegt werden.'];
$set[CURLOPT_RETURNTRANSFER] = false;
$set[CURLOPT_FILE] = $out;
}
if (isset($opt['file'])) {
$fh = fopen($opt['file'], 'rb');
if ($fh === false) return ['code' => 0, 'body' => '', 'error' => 'Die Backup-Datei konnte nicht gelesen werden.'];
$set[CURLOPT_UPLOAD] = true;
$set[CURLOPT_INFILE] = $fh;
$set[CURLOPT_INFILESIZE] = filesize($opt['file']);
$headers[] = 'Expect:';
} elseif (isset($opt['body'])) {
$set[CURLOPT_POSTFIELDS] = $opt['body'];
}
if ($method === 'HEAD') { $set[CURLOPT_NOBODY] = true; $set[CURLOPT_HEADER] = true; }
if ($headers) $set[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $set);
$body = curl_exec($ch);
$res = ['code' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => is_string($body) ? $body : '', 'error' => $body === false ? curl_error($ch) : ''];
curl_close($ch);
if ($fh) fclose($fh);
if ($out) fclose($out);
return $res;
}
/** Verständliche Fehlermeldung zu einer Antwort. */
function nasbk_explain(array $r, string $what): string
{
$c = $r['code'];
if ($c === 0) {
$e = $r['error'];
$hint = stripos($e, 'ssl') !== false || stripos($e, 'certificate') !== false
? ' Tipp: Hat die NAS ein selbst erstelltes Zertifikat, schalte die Zertifikatsprüfung in den Einstellungen aus (weniger sicher) oder installiere ein Zertifikat von Let’s Encrypt.' : '';
return "{$what}: keine Verbindung ({$e}).{$hint}";
}
return match (true) {
$c === 401 => "{$what}: Anmeldung abgelehnt. Benutzer oder Passwort stimmen nicht (bei Synology: Benutzer braucht Zugriff auf WebDAV und den Ordner).",
$c === 403 => "{$what}: Zugriff verweigert (HTTP 403). Der Benutzer darf dort nicht schreiben.",
$c === 404 => "{$what}: Pfad nicht gefunden (HTTP 404). Bei Synology lautet die Adresse https://<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).",
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. @return array{ok:bool,items:array<int,array{name:string,size:int,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')];
$prev = libxml_use_internal_errors(true);
$xml = simplexml_load_string($r['body'], 'SimpleXMLElement', LIBXML_NONET);
libxml_use_internal_errors($prev);
if ($xml === false) return ['ok' => false, 'items' => [], 'error' => 'Die Antwort der NAS ließ sich nicht lesen.'];
$xml->registerXPathNamespace('d', 'DAV:');
$items = [];
foreach ($xml->xpath('//d:response') ?: [] as $resp) {
$resp->registerXPathNamespace('d', 'DAV:');
$href = (string) ($resp->xpath('d:href')[0] ?? '');
$name = basename(rawurldecode(rtrim($href, '/')));
if ($name === '' || !preg_match(nasbk_name_pattern(), $name)) continue;
$items[] = [
'name' => $name,
'size' => (int) ($resp->xpath('.//d:getcontentlength')[0] ?? 0),
'modified' => (string) ($resp->xpath('.//d:getlastmodified')[0] ?? ''),
];
}
usort($items, fn($a, $b) => strcmp($b['name'], $a['name'])); // Namen enthalten das Datum: neueste zuerst
return ['ok' => true, 'items' => $items, 'error' => ''];
}
/** Nur Dateien, die dieses Plugin selbst anlegt, werden aufgelistet und bei der Aufbewahrung gelöscht. */
function nasbk_name_pattern(): string
{
return '/^backup_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:_[a-z0-9]{3})?\.zip$/';
}
/** Größe einer Datei auf dem Server (null, wenn sie sich nicht ermitteln lässt). */
function nasbk_remote_size(array $cfg, string $url): ?int
{
$r = nasbk_request($cfg, 'PROPFIND', $url, ['body' => '<?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;
}
/** Lädt die Datei hoch und prüft die Größe. Gibt eine Fehlermeldung oder null zurück. */
function nasbk_upload(array $cfg, string $localPath, string $name): ?string
{
$url = nasbk_remote_url($cfg, $name);
$size = (int) filesize($localPath);
$timeout = max(300, (int) ceil($size / 1048576) * 20); // mindestens 5 Minuten, sonst rund 20 s je MB
$r = nasbk_request($cfg, 'PUT', $url, ['file' => $localPath, 'timeout' => $timeout]);
if (!in_array($r['code'], [200, 201, 204], true)) return nasbk_explain($r, 'Hochladen');
$remote = nasbk_remote_size($cfg, $url);
if ($remote !== null && $remote !== $size) {
nasbk_request($cfg, 'DELETE', $url, ['timeout' => 30]);
return "Hochladen: Die Datei auf der NAS ist unvollständig ({$remote} statt {$size} Bytes) und wurde wieder gelöscht.";
}
return null;
}
/** Löscht Sicherungen nach den beiden Regeln „älter als X Tage“ und „mehr als Y Stück“ (0 = Regel aus). @return array{deleted:string[],error:string} */
function nasbk_rotate(array $cfg): array
{
if ($cfg['keep'] <= 0 && $cfg['keep_days'] <= 0) return ['deleted' => [], 'error' => ''];
$list = nasbk_list($cfg);
if (!$list['ok']) return ['deleted' => [], 'error' => $list['error']];
$items = $list['items']; // neueste zuerst (Namen enthalten das Datum)
$drop = [];
if ($cfg['keep_days'] > 0) {
$cutoff = strtotime("-{$cfg['keep_days']} days");
foreach ($items as $it) {
if (preg_match('/^backup_(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})/', $it['name'], $m) && strtotime("{$m[1]} {$m[2]}:{$m[3]}:{$m[4]}") < $cutoff) $drop[$it['name']] = true;
}
}
if ($cfg['keep'] > 0) foreach (array_slice($items, $cfg['keep']) as $it) $drop[$it['name']] = true;
$deleted = [];
foreach (array_keys($drop) as $name) {
$r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]);
if (in_array($r['code'], [200, 204, 404], true)) $deleted[] = $name;
}
return ['deleted' => $deleted, 'error' => ''];
}
/** Hauptordner von DeskHub (Ort der Datei, die get_db() enthält: includes/db.php). */
function nasbk_root(): string
{
return dirname((new ReflectionFunction('get_db'))->getFileName(), 2);
}
/** Löscht einen Ordner samt Inhalt (nur der eigene Zwischenordner im Temp-Verzeichnis). */
function nasbk_rmtree(string $dir): void
{
if (!is_dir($dir) || !str_contains(basename($dir), 'deskhub-nasbk-')) return;
$walk = function (string $d) use (&$walk): void {
foreach (scandir($d) ?: [] as $e) {
if ($e === '.' || $e === '..') continue;
$p = $d . '/' . $e;
is_dir($p) && !is_link($p) ? $walk($p) : @unlink($p);
}
@rmdir($d);
};
$walk($dir);
}
/**
* Erzeugt das Backup in einem Zwischenordner (außerhalb des Webverzeichnisses) mit den Einstellungen des Plugins:
* Datenbank und/oder Dateien (wichtigste Dateien oder alles), ohne die ausgeschlossenen Pfade.
*
* @return array{ok:bool,error?:string,path?:string,filename?:string,size?:float,tmp?:string}
*/
function nasbk_make_archive(array $cfg): array
{
$root = nasbk_root();
if (!function_exists('create_backup')) require_once $root . '/includes/backup.php';
if (!function_exists('backup_verify')) require_once $root . '/includes/backup_restore.php';
if (!$cfg['with_database'] && !$cfg['with_files']) return ['ok' => false, 'error' => 'Weder Datenbank noch Dateien sind zum Sichern ausgewählt.'];
$tmp = sys_get_temp_dir() . '/deskhub-nasbk-' . bin2hex(random_bytes(4));
if (!@mkdir($tmp . '/out', 0700, true)) return ['ok' => false, 'error' => 'Der Zwischenordner für das Backup konnte nicht angelegt werden.'];
$options = ['with_database' => $cfg['with_database'], 'with_files' => $cfg['with_files'], 'exclude' => $cfg['excludes'], 'verify' => false]; // das Plugin prüft selbst (ohne Eintrag in die Prüfliste der Backup-Seite)
// „Wichtigste Dateien“: Daten, die sich nicht neu herunterladen lassen (Uploads, Plugins, Konfiguration, Logo/Branding)
if ($cfg['with_files'] && $cfg['scope'] === 'important') $options['only'] = ['uploads', 'plugins', 'config', 'assets/branding', '.htaccess'];
$GLOBALS['nasbk_creating'] = true; // eigenes Backup des Plugins: nicht zusätzlich in die Warteschlange der Backup-Seite aufnehmen
try {
$res = create_backup($root, $tmp . '/out', null, $options);
} finally {
$GLOBALS['nasbk_creating'] = false;
}
if (empty($res['ok'])) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => (string) ($res['error'] ?? 'Backup fehlgeschlagen.')]; }
if ($cfg['with_database']) {
$check = backup_verify($res['path']);
if (empty($check['ok'])) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => 'Das erzeugte Backup ist nicht in Ordnung: ' . implode(' ', (array) ($check['errors'] ?? []))]; }
} else {
$z = new ZipArchive();
if ($z->open($res['path']) !== true || $z->numFiles < 1) { nasbk_rmtree($tmp); return ['ok' => false, 'error' => 'Das erzeugte Backup lässt sich nicht öffnen oder ist leer.']; }
$z->close();
}
return ['ok' => true, 'path' => $res['path'], 'filename' => $res['filename'], 'size' => (float) $res['size'], 'tmp' => $tmp];
}
/** Hält das Ergebnis fest (Anzeige auf der Einstellungsseite) und meldet Fehler höchstens einmal am Tag per E-Mail. */
function nasbk_record(bool $ok, string $message, bool $manual): void
{
$now = date('Y-m-d H:i:s');
plugin_set_setting(NASBK, 'last_run', $now);
plugin_set_setting(NASBK, 'last_ok', $ok ? '1' : '0');
plugin_set_setting(NASBK, 'last_message', $message);
if ($ok) plugin_set_setting(NASBK, 'last_ok_at', $now);
$hist = json_decode((string) plugin_setting(NASBK, 'history', '[]'), true);
$hist = is_array($hist) ? $hist : [];
array_unshift($hist, ['time' => $now, 'ok' => $ok, 'message' => $message, 'manual' => $manual]);
plugin_set_setting(NASBK, 'history', json_encode(array_slice($hist, 0, 15), JSON_UNESCAPED_UNICODE));
if (!$ok && !$manual) {
$last = strtotime((string) plugin_setting(NASBK, 'last_mail', '')) ?: 0;
$to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
if ($to !== '' && time() - $last > 86400 && function_exists('send_mail')) {
plugin_set_setting(NASBK, 'last_mail', $now);
@send_mail($to, 'DeskHub: NAS-Backup fehlgeschlagen', '<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]; }
$arch = nasbk_make_archive($cfg);
if (!$arch['ok']) { nasbk_record(false, $arch['error'], $manual); return ['ok' => false, 'message' => $arch['error']]; }
try {
if ($err = nasbk_ensure_folder($cfg)) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; }
if ($err = nasbk_upload($cfg, $arch['path'], $arch['filename'])) { nasbk_record(false, $err, $manual); return ['ok' => false, 'message' => $err]; }
$rot = nasbk_rotate($cfg);
$msg = $arch['filename'] . ' (' . $arch['size'] . ' MB, ' . ($cfg['mode'] === 'database' ? 'nur Datenbank' : 'komplett') . ') auf die NAS hochgeladen'
. ($rot['deleted'] ? '; ' . count($rot['deleted']) . ' alte Sicherung(en) gelöscht' : '')
. ($rot['error'] !== '' ? '; Aufbewahrung nicht ausgeführt: ' . $rot['error'] : '') . '.';
nasbk_record(true, $msg, $manual);
return ['ok' => true, 'message' => $msg];
} finally {
nasbk_rmtree($arch['tmp']);
}
}
/** Läuft gerade ein manuell gestartetes Backup? (Markierung verfällt nach 2 Stunden, falls ein Lauf abgebrochen wurde) */
function nasbk_running(): bool
{
$t = (int) plugin_setting(NASBK, 'running_since', '0');
return $t > 0 && time() - $t < 7200;
}
/** Startet ein eigenes Backup nach dem Senden der Antwort (das Seitenladen wartet nicht auf das Backup). */
function nasbk_run_background(PDO $pdo): void
{
plugin_set_setting(NASBK, 'running_since', (string) time());
register_shutdown_function(function () use ($pdo) {
if (function_exists('fastcgi_finish_request')) @fastcgi_finish_request();
@ignore_user_abort(true);
try {
nasbk_run($pdo, true);
} catch (Throwable $e) {
nasbk_record(false, 'Backup abgebrochen: ' . $e->getMessage(), true);
} finally {
plugin_set_setting(NASBK, 'running_since', '0');
}
});
}
/** Prüft die Verbindung: Ordner anlegen, kleine Testdatei schreiben, Größe lesen, löschen. @return array{ok:bool,message:string} */
function nasbk_test(array $cfg): array
{
if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err];
if ($err = nasbk_ensure_folder($cfg)) return ['ok' => false, 'message' => $err];
$tmp = tempnam(sys_get_temp_dir(), 'nasbk');
file_put_contents($tmp, 'DeskHub NAS-Backup Verbindungstest ' . date('c'));
$name = 'verbindungstest-' . bin2hex(random_bytes(3)) . '.txt';
$url = nasbk_remote_url($cfg, $name);
$put = nasbk_request($cfg, 'PUT', $url, ['file' => $tmp, 'timeout' => 60]);
@unlink($tmp);
if (!in_array($put['code'], [200, 201, 204], true)) return ['ok' => false, 'message' => nasbk_explain($put, 'Testdatei schreiben')];
$del = nasbk_request($cfg, 'DELETE', $url, ['timeout' => 30]);
$note = in_array($del['code'], [200, 204], true) ? '' : ' Die Testdatei konnte nicht wieder gelöscht werden (Löschrecht prüfen, sonst bleiben alte Sicherungen liegen).';
return ['ok' => true, 'message' => 'Verbindung in Ordnung: Der Ordner „' . ($cfg['folder'] ?: '/') . '“ ist erreichbar und beschreibbar.' . $note];
}
/** Wird alle ~5 Minuten vom Plugin-Takt aufgerufen: kopiert neue Backups des Servers auf die NAS und startet ein eigenes Backup, wenn es laut Zeitplan fällig ist. */
function nasbk_tick(PDO $pdo): void
{
$cfg = nasbk_config();
if (!$cfg['enabled'] || nasbk_config_error($cfg)) return;
nasbk_sync_local($pdo, false);
if (!$cfg['own_schedule']) return;
$interval = nasbk_intervals()[$cfg['interval']][1];
$now = time();
$lastOk = strtotime((string) plugin_setting(NASBK, 'last_ok_at', '')) ?: 0;
$lastRun = strtotime((string) plugin_setting(NASBK, 'last_run', '')) ?: 0;
if ($now - $lastOk < $interval - min(3600, intdiv($interval, 10))) return; // noch nicht fällig
if ($interval >= 86400 && $lastOk > 0 && date('H:i') < $cfg['time']) return; // täglich/wöchentlich/monatlich erst ab der eingestellten Uhrzeit
if ($now - $lastRun < 3600) return; // nach einem Fehler frühestens nach einer Stunde erneut
if (function_exists('pseudo_cron_try_claim') && !pseudo_cron_try_claim($pdo, 'plugin_nas_backup_lock', 600)) return;
nasbk_run($pdo, false);
}
// ─── Zusammenarbeit mit der Backup-Seite ─────────────────────────────────────
/** Dateiname eines Backups, das sich sicher verwenden lässt (kein Pfad, nur .zip im Namensschema). */
function nasbk_valid_name(string $name): bool
{
return $name === basename($name) && preg_match(nasbk_name_pattern(), $name) === 1;
}
/** Ordner mit den lokalen Backups von DeskHub. */
function nasbk_local_dir(): string
{
// Nur im Testbetrieb (DESKHUB_TESTING=1) darf ein anderer Ordner gesetzt werden
if (getenv('DESKHUB_TESTING') === '1' && getenv('DESKHUB_BACKUP_DIR')) return rtrim((string) getenv('DESKHUB_BACKUP_DIR'), '/\\');
return nasbk_root() . '/backups';
}
/** Pfad einer lokalen Sicherung oder null. */
function nasbk_local_path(string $name): ?string
{
if (!nasbk_valid_name($name)) return null;
$dir = realpath(nasbk_local_dir());
$p = $dir ? $dir . '/' . $name : null;
return $p && is_file($p) ? $p : null;
}
/** Lädt eine lokale Sicherung auf die NAS, kontrolliert die Größe und räumt nach der Aufbewahrungsregel auf. @return array{ok:bool,message:string} */
function nasbk_push_file(array $cfg, string $path, string $name): array
{
@set_time_limit(0);
if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err];
if ($err = nasbk_ensure_folder($cfg)) return ['ok' => false, 'message' => $err];
if ($err = nasbk_upload($cfg, $path, $name)) return ['ok' => false, 'message' => $err];
$rot = nasbk_rotate($cfg);
nasbk_cache_clear();
return ['ok' => true, 'message' => $name . ' auf die NAS kopiert' . ($rot['deleted'] ? ' (' . count($rot['deleted']) . ' alte Sicherung(en) dort gelöscht)' : '') . '.'];
}
/** Zwischenspeicher der NAS-Liste (damit die Backup-Seite nicht bei jedem Aufruf die NAS befragt). */
function nasbk_cache_clear(): void
{
plugin_set_setting(NASBK, 'remote_cache', '');
}
/** @return array{ok:bool,items:array,error:string,at:int} */
function nasbk_remote_cached(array $cfg): array
{
$c = json_decode((string) plugin_setting(NASBK, 'remote_cache', ''), true);
if (is_array($c) && time() - (int) ($c['at'] ?? 0) < (!empty($c['ok']) ? 60 : 180)) return $c;
$l = nasbk_list($cfg, 8);
$l['at'] = time();
plugin_set_setting(NASBK, 'remote_cache', json_encode($l, JSON_UNESCAPED_UNICODE));
return $l;
}
/** Speichert die Einstellungen aus dem Formular. @return array{ok:bool,message:string} */
function nasbk_save_settings(array $post, array $staff = []): array
{
$host = trim((string) ($post['host'] ?? ''));
$host = preg_replace('#^https?://#i', '', $host);
$host = rtrim(explode('/', $host)[0], ':');
$https = !empty($post['https']);
$port = (int) ($post['port'] ?? 0);
if ($port < 1 || $port > 65535) $port = $https ? 443 : 80;
$target = '/' . trim(trim((string) ($post['target'] ?? '')), '/') . '/';
$default = ($https && $port === 443) || (!$https && $port === 80);
$err = nasbk_url_error(($https ? 'https' : 'http') . '://' . $host . ($default ? '' : ':' . $port));
if (!$err && empty($post['db']) && empty($post['files']) && !empty($post['own_schedule'])) $err = 'Für den eigenen Zeitplan bitte mindestens „Datenbank“ oder „Dateien“ auswählen.';
if ($err) return ['ok' => false, 'message' => $err];
$interval = (string) ($post['interval'] ?? 'daily');
$time = (string) ($post['time'] ?? '02:00');
plugin_set_setting(NASBK, 'enabled', !empty($post['enabled']) ? '1' : '0');
plugin_set_setting(NASBK, 'host', $host);
plugin_set_setting(NASBK, 'port', (string) $port);
plugin_set_setting(NASBK, 'https', $https ? '1' : '0');
plugin_set_setting(NASBK, 'url', ''); // alte Einstellung durch die neuen Felder ersetzt
plugin_set_setting(NASBK, 'folder', '');
plugin_set_setting(NASBK, 'target', $target);
plugin_set_setting(NASBK, 'user', trim((string) ($post['user'] ?? '')));
$pw = (string) ($post['password'] ?? '');
if ($pw !== '') plugin_set_setting(NASBK, 'password', encrypt_secret($pw)); // leer = gespeichertes Passwort behalten
plugin_set_setting(NASBK, 'verify_tls', !empty($post['verify_tls']) ? '1' : '0');
plugin_set_setting(NASBK, 'mirror', !empty($post['mirror']) ? '1' : '0');
plugin_set_setting(NASBK, 'own_schedule', !empty($post['own_schedule']) ? '1' : '0');
plugin_set_setting(NASBK, 'db', !empty($post['db']) ? '1' : '0');
plugin_set_setting(NASBK, 'files', !empty($post['files']) ? '1' : '0');
plugin_set_setting(NASBK, 'scope', ($post['scope'] ?? 'important') === 'full' ? 'full' : 'important');
plugin_set_setting(NASBK, 'excludes', mb_substr(trim((string) ($post['excludes'] ?? '')), 0, 4000));
plugin_set_setting(NASBK, 'interval', isset(nasbk_intervals()[$interval]) ? $interval : 'daily');
plugin_set_setting(NASBK, 'time', preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time) ? $time : '02:00');
plugin_set_setting(NASBK, 'keep_days', (string) max(0, min(3650, (int) ($post['keep_days'] ?? 0))));
plugin_set_setting(NASBK, 'keep', (string) max(0, min(365, (int) ($post['keep'] ?? 14))));
nasbk_cache_clear();
return ['ok' => true, 'message' => 'Einstellungen gespeichert.'];
}
/** Holt eine Sicherung von der NAS in den Backup-Ordner und prüft sie. @return array{ok:bool,message:string} */
function nasbk_fetch(array $cfg, string $name): array
{
@set_time_limit(0);
if (!nasbk_valid_name($name)) return ['ok' => false, 'message' => 'Ungültiger Dateiname.'];
if ($err = nasbk_config_error($cfg)) return ['ok' => false, 'message' => $err];
$dir = realpath(nasbk_local_dir());
if (!$dir || !is_writable($dir)) return ['ok' => false, 'message' => 'Der Ordner backups auf dem Server ist nicht beschreibbar.'];
$target = $dir . '/' . $name;
if (is_file($target)) return ['ok' => false, 'message' => 'Diese Sicherung liegt schon auf dem Server.'];
$tmp = $dir . '/.nas-' . bin2hex(random_bytes(4)) . '.part';
$r = nasbk_request($cfg, 'GET', nasbk_remote_url($cfg, $name), ['save_to' => $tmp, 'timeout' => 3600]);
if ($r['code'] !== 200) { @unlink($tmp); return ['ok' => false, 'message' => nasbk_explain($r, 'Herunterladen')]; }
$expected = nasbk_remote_size($cfg, nasbk_remote_url($cfg, $name));
if ($expected !== null && filesize($tmp) !== $expected) { @unlink($tmp); return ['ok' => false, 'message' => 'Herunterladen: Die Datei ist unvollständig angekommen. Bitte erneut versuchen.']; }
if (!function_exists('backup_verify')) require_once nasbk_root() . '/includes/backup_restore.php';
$check = backup_verify($tmp);
if (empty($check['ok'])) { @unlink($tmp); return ['ok' => false, 'message' => 'Die Sicherung von der NAS ist nicht in Ordnung: ' . implode(' ', (array) ($check['errors'] ?? []))]; }
if (!@rename($tmp, $target)) { @unlink($tmp); return ['ok' => false, 'message' => 'Die Datei konnte nicht in den Backup-Ordner verschoben werden.']; }
if (function_exists('backup_verify_and_record')) backup_verify_and_record($target);
return ['ok' => true, 'message' => $name . ' von der NAS geholt und geprüft. Du kannst sie jetzt in der Liste wiederherstellen.'];
}
// ─── Kopieren der Backups dieses Servers ─────────────────────────────────────
/**
* Kopiert Backups aus dem Backup-Ordner des Servers (die DeskHub selbst erstellt hat: Zeitplan der Seite „Backup“, manuell, vor Updates)
* auf die NAS. Nur Backups, die nicht als defekt geprüft wurden; ohne Prüfergebnis erst nach 20 Minuten (die automatische Prüfung
* läuft alle 10 Minuten). Betrachtet werden die neuesten Backups im Rahmen der Aufbewahrung (Anzahl/Alter), damit nicht alte
* Sicherungen hochgeladen und gleich wieder gelöscht werden.
*
* @return array{ok:bool,done:int,message:string}
*/
function nasbk_sync_local(PDO $pdo, bool $manual = false, int $max = 2): array
{
$cfg = nasbk_config();
if (!$manual && (!$cfg['enabled'] || !$cfg['mirror'])) return ['ok' => true, 'done' => 0, 'message' => ''];
if ($e = nasbk_config_error($cfg)) return ['ok' => false, 'done' => 0, 'message' => $e];
if (!$manual) {
$failAt = strtotime((string) plugin_setting(NASBK, 'sync_fail_at', '')) ?: 0;
if (time() - $failAt < 3600) return ['ok' => true, 'done' => 0, 'message' => '']; // nach einem Fehler frühestens nach einer Stunde erneut
if (function_exists('pseudo_cron_try_claim') && !pseudo_cron_try_claim($pdo, 'plugin_nas_backup_sync', 300)) return ['ok' => true, 'done' => 0, 'message' => ''];
}
@set_time_limit(0);
$dir = realpath(nasbk_local_dir());
if (!$dir) return ['ok' => true, 'done' => 0, 'message' => ''];
$files = [];
foreach (glob($dir . '/backup_*.zip') ?: [] as $p) if (nasbk_valid_name(basename($p))) $files[$p] = filemtime($p);
arsort($files);
$limit = $cfg['keep'] > 0 ? $cfg['keep'] : 10;
$files = array_slice($files, 0, $limit, true);
if ($cfg['keep_days'] > 0) $files = array_filter($files, fn($t) => $t >= strtotime("-{$cfg['keep_days']} days"));
if (!$files) return ['ok' => true, 'done' => 0, 'message' => ''];
$remote = nasbk_list($cfg, 20);
if (!$remote['ok']) return nasbk_sync_fail($remote['error'], $manual);
$have = [];
foreach ($remote['items'] as $it) $have[$it['name']] = $it['size'];
$checks = json_decode((string) get_setting('backup_check_map', ''), true);
$checks = is_array($checks) ? $checks : [];
$done = 0;
$skippedBad = 0;
foreach ($files as $path => $mtime) {
$name = basename($path);
if (isset($have[$name]) && $have[$name] === filesize($path)) continue; // liegt schon vollständig auf der NAS
$chk = $checks[$name] ?? null;
if ($chk && empty($chk['ok'])) { $skippedBad++; continue; } // defekte Backups nicht kopieren
if (!$chk && !$manual && time() - $mtime < 1200) continue; // noch nicht geprüft: kurz warten
if (!$manual && $done >= $max) break;
$r = nasbk_push_file($cfg, $path, $name);
if (!$r['ok']) return nasbk_sync_fail($name . ': ' . $r['message'], $manual);
nasbk_record(true, $r['message'], $manual);
$done++;
}
if ($done > 0) nasbk_cache_clear();
$msg = $done . ' Sicherung(en) auf die NAS kopiert' . ($skippedBad ? '; ' . $skippedBad . ' defekte(s) Backup(s) übersprungen' : '') . '.';
return ['ok' => true, 'done' => $done, 'message' => $msg];
}
/** Fehler beim Kopieren festhalten (E-Mail an die Admin-Adresse höchstens einmal am Tag) und weitere Versuche für eine Stunde aussetzen. */
function nasbk_sync_fail(string $message, bool $manual): array
{
plugin_set_setting(NASBK, 'sync_fail_at', date('Y-m-d H:i:s'));
nasbk_record(false, 'Kopie auf die NAS fehlgeschlagen: ' . $message, $manual);
return ['ok' => false, 'done' => 0, 'message' => $message];
}
/** Adresse der Plugin-Seite (Reiter unter „Backup“). */
function nasbk_page_url(): string
{
return '/staff/plugin.php?p=' . NASBK . '&page=einstellungen';
}
/** Kleines Formular, das eine Aktion dieser Seite auslöst ($target: andere Adresse, wenn es auf der Seite „Backup“ steht). */
function nasbk_action_form(string $do, string $label, array $extra = [], string $confirm = '', string $class = 'secondary', string $style = '', string $target = ''): string
{
$h = '<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':
if (nasbk_running()) return ['ok' => false, 'message' => 'Es läuft bereits ein Backup. Bitte kurz warten.'];
nasbk_run_background(get_db());
return ['ok' => true, 'message' => 'Das eigene Backup läuft jetzt im Hintergrund. Das Ergebnis steht oben im Status; die Seite lädt sich von selbst neu.'];
case 'sync':
nasbk_cache_clear();
$r = nasbk_sync_local(get_db(), true);
return ['ok' => $r['ok'], 'message' => $r['message']];
case 'upload':
$path = nasbk_local_path($name);
if ($path === null) return ['ok' => false, 'message' => 'Backup nicht gefunden.'];
$r = nasbk_push_file($cfg, $path, $name);
nasbk_record($r['ok'], $r['message'], true);
return $r;
case 'fetch':
return nasbk_fetch($cfg, $name);
case 'delete_remote':
if (!nasbk_valid_name($name) || nasbk_config_error($cfg)) return ['ok' => false, 'message' => 'Ungültige Anfrage.'];
$r = nasbk_request($cfg, 'DELETE', nasbk_remote_url($cfg, $name), ['timeout' => 60]);
nasbk_cache_clear();
return in_array($r['code'], [200, 204], true) ? ['ok' => true, 'message' => $name . ' von der NAS gelöscht.'] : ['ok' => false, 'message' => nasbk_explain($r, 'Löschen')];
}
return ['ok' => false, 'message' => 'Unbekannte Aktion.'];
}
/**
* Ergänzt die Liste „Vorhandene Backups“ auf der Seite „Backup“ (Filter backup.list.rows): Marke „auf NAS“ / „nur hier“ bei den Backups des
* Servers, Knopf „Auf NAS“ und Sicherungen, die nur auf der NAS liegen (mit „Auf Server holen“). Die Knöpfe senden an die Seite des Plugins.
*/
function nasbk_list_rows(array $rows, array $ctx = []): array
{
$cfg = nasbk_config();
if (!$cfg['enabled'] || nasbk_config_error($cfg) !== null) return $rows;
$remote = nasbk_remote_cached($cfg);
$url = nasbk_page_url();
$byName = [];
if ($remote['ok']) foreach ($remote['items'] as $it) $byName[$it['name']] = $it;
foreach ($rows as &$r) {
if (($r['source'] ?? 'local') !== 'local' || !nasbk_valid_name((string) $r['name'])) continue;
if (!$remote['ok']) { $r['badges'][] = '<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;
}