mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 09:53:08 +02:00
- surface backup/cleanup cron logs + backup freshness status - email xamxam@erg.be when SQLite backups go stale (backup watchdog) - sync SQLite snapshots to Nextcloud WebDAV + remote-freshness watchdog - precise retention pruning, manual sync in check recipe, and Nextcloud-sync docs
258 lines
8.4 KiB
PHP
258 lines
8.4 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
/**
|
|
* nextcloud-sync.php — push the latest SQLite snapshot to Nextcloud WebDAV.
|
|
*
|
|
* Uploads the newest db-*.db.gz from /var/backups/xamxam to the XAMXAM backup
|
|
* folder on cloud.erg.school, reusing the SMTP credentials (xamxam@erg.be)
|
|
* already stored/decryptable in smtp_settings. Filenames are timestamped and
|
|
* the remote folder is pruned to the most recent N snapshots.
|
|
*
|
|
* Transport is PHP's curl extension (no rclone / shell curl needed): WebDAV
|
|
* upload is an HTTP PUT with Basic auth, then a PROPFIND to verify size.
|
|
*
|
|
* Usage:
|
|
* php scripts/nextcloud-sync.php # keep 7 remote snapshots
|
|
* REMOTE_KEEP=14 php scripts/nextcloud-sync.php
|
|
*
|
|
* Expected to run from cron shortly after the daily backup snapshot.
|
|
* Exit codes: 0 on success, 1 on failure (logged, never fatal to the backup).
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
// Resolve APP_ROOT robustly — same convention as the other CLI scripts here.
|
|
$prodRoot = '/var/www/xamxam';
|
|
if (is_dir($prodRoot . '/src') && is_file($prodRoot . '/src/Database.php')) {
|
|
define('APP_ROOT', $prodRoot);
|
|
} else {
|
|
define('APP_ROOT', dirname(__DIR__) . '/app');
|
|
}
|
|
|
|
const BACKUP_DIR = '/var/backups/xamxam';
|
|
const WEBDAV_BASE = 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK';
|
|
const REMOTE_PREFIX = 'xamxam-db-';
|
|
|
|
// Autoload dependencies (PHPMailer, etc.) from composer.
|
|
$autoloads = [
|
|
APP_ROOT . '/../vendor/autoload.php', // repo root / vendor
|
|
APP_ROOT . '/vendor/autoload.php', // app / vendor
|
|
];
|
|
foreach ($autoloads as $a) {
|
|
if (is_file($a)) {
|
|
require_once $a;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Read-only probe: do NOT run schema migrations against the live DB just to
|
|
// sync a snapshot. No-op DatabaseMigrations so the Database constructor only
|
|
// opens a PDO connection for reading SMTP credentials.
|
|
if (!class_exists('DatabaseMigrations', false)) {
|
|
class DatabaseMigrations
|
|
{
|
|
public function run(): void {}
|
|
}
|
|
}
|
|
|
|
require_once APP_ROOT . '/src/Crypto.php';
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
require_once APP_ROOT . '/src/SmtpRelay.php';
|
|
|
|
if (!extension_loaded('curl')) {
|
|
error_log('[nextcloud-sync] curl extension not available');
|
|
exit(1);
|
|
}
|
|
|
|
// Remote retention: keep the most recent N snapshots.
|
|
$remoteKeep = (int) (getenv('REMOTE_KEEP') ?: 7);
|
|
if ($remoteKeep < 1) {
|
|
$remoteKeep = 7;
|
|
}
|
|
|
|
// Locate the newest local snapshot.
|
|
$newest = null;
|
|
$newestMtime = 0;
|
|
foreach (glob(BACKUP_DIR . '/db-*.db.gz') ?: [] as $f) {
|
|
$m = @filemtime($f);
|
|
if ($m !== false && $m > $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:getcontentlength[^>]*>(\d+)<\/d:getcontentlength>/i', $body, $m)) {
|
|
return (int) $m[1];
|
|
}
|
|
// Fallback: any numeric "getcontentlength" (namespace may differ).
|
|
if (preg_match('/getcontentlength[^>]*>(\d+)</i', $body, $m)) {
|
|
return (int) $m[1];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* List remote snapshots and delete the oldest beyond $keep (by name order).
|
|
*/
|
|
function pruneRemote(string $user, string $pass, int $keep): bool
|
|
{
|
|
$ch = curl_init(rtrim(WEBDAV_BASE, '/') . '/');
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_CUSTOMREQUEST => '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>([^<]+)<\/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;
|
|
}
|