#!/usr/bin/env php $newestMtime) { $newestMtime = $m; $newest = $f; } } if ($newest === null) { error_log('[nextcloud-sync] No local snapshot found in ' . BACKUP_DIR); exit(1); } $localSize = filesize($newest); $suffix = substr(basename($newest), 0, -strlen('.db.gz')); // strip .db.gz $remoteName = REMOTE_PREFIX . $suffix . '.db.gz'; $remoteUrl = rtrim(WEBDAV_BASE, '/') . '/' . rawurlencode($remoteName); try { $db = new Database(); $s = SmtpRelay::getSettings($db); $user = $s['username']; $pass = $s['password']; if ($user === '' || $pass === '') { error_log('[nextcloud-sync] SMTP credentials missing (username/password)'); exit(1); } $ch = curl_init($remoteUrl); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_USERPWD => $user . ':' . $pass, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_UPLOAD => true, // stream upload, memory-safe CURLOPT_INFILE => fopen($newest, 'rb'), CURLOPT_INFILESIZE => $localSize, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 0, // no timeout on large uploads CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FOLLOWLOCATION => false, ]); $resp = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($resp === false || ($code !== 201 && $code !== 204)) { error_log("[nextcloud-sync] PUT failed (HTTP {$code}): {$err}"); exit(1); } // Integrity check — PROPFIND the uploaded file and confirm size matches. $remoteSize = webdavSize($remoteUrl, $user, $pass); if ($remoteSize !== $localSize) { error_log( "[nextcloud-sync] size mismatch after upload: local {$localSize} vs remote " . ($remoteSize === null ? 'unknown' : $remoteSize) ); exit(1); } echo "[nextcloud-sync] Uploaded {$remoteName} ({$localSize} bytes) to XAMXAM-BCK\n"; error_log("[nextcloud-sync] Uploaded {$remoteName} ({$localSize} bytes)"); // Prune remote to the most recent N files (by name, which is timestamped). pruneRemote($user, $pass, $remoteKeep); exit(0); } catch (Throwable $e) { error_log('[nextcloud-sync] Error: ' . $e->getMessage()); exit(1); } /** * PROPFIND the remote file and return its size in bytes, or null on failure. */ function webdavSize(string $url, string $user, string $pass): ?int { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'PROPFIND', CURLOPT_USERPWD => $user . ':' . $pass, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_HTTPHEADER => ['Depth: 0'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body === false || $code !== 207) { return null; } // Nextcloud returns getcontentlength in the PROPFIND response. if (preg_match('/]*>(\d+)<\/d:getcontentlength>/i', $body, $m)) { return (int) $m[1]; } // Fallback: any numeric "getcontentlength" (namespace may differ). if (preg_match('/getcontentlength[^>]*>(\d+) 'PROPFIND', CURLOPT_USERPWD => $user . ':' . $pass, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_HTTPHEADER => ['Depth: 1'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body === false || $code !== 207) { error_log("[nextcloud-sync] prune: PROPFIND failed (HTTP {$code})"); return false; } // Extract hrefs of our snapshot files (names starting with REMOTE_PREFIX). $names = []; if (preg_match_all('/([^<]+)<\/d:href>/i', $body, $m)) { foreach ($m[1] as $href) { $decoded = rawurldecode(basename(rtrim($href, '/'))); if (str_starts_with($decoded, REMOTE_PREFIX) && str_ends_with($decoded, '.db.gz')) { $names[] = $decoded; } } } $names = array_values(array_unique($names)); sort($names); // ascending; oldest last become deletable $excess = count($names) - $keep; if ($excess <= 0) { return true; } $toDelete = array_slice($names, 0, $excess); // oldest first foreach ($toDelete as $name) { $url = rtrim(WEBDAV_BASE, '/') . '/' . rawurlencode($name); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_USERPWD => $user . ':' . $pass, CURLOPT_HTTPAUTH => CURLAUTH_BASIC, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); curl_exec($ch); $delCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($delCode === 204 || $delCode === 404) { echo "[nextcloud-sync] Pruned remote {$name}\n"; } else { error_log("[nextcloud-sync] prune: DELETE {$name} failed (HTTP {$delCode})"); } } return true; }