mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
admin: backup logs via parameters.php, nextcloud secondary backup
- 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
This commit is contained in:
@@ -7,6 +7,10 @@
|
|||||||
- [x] Fix language metadata link using wrong `query=` param instead of dedicated `language=` filter
|
- [x] Fix language metadata link using wrong `query=` param instead of dedicated `language=` filter
|
||||||
- [x] Sort TFE files by display category on public page (note d'intention → TFE → image → video → audio → website → annexes)
|
- [x] Sort TFE files by display category on public page (note d'intention → TFE → image → video → audio → website → annexes)
|
||||||
- [x] Fix CSV import: VALUES had 24 ? placeholders but execute array has 23 values → 27 total for 26 columns
|
- [x] Fix CSV import: VALUES had 24 ? placeholders but execute array has 23 values → 27 total for 26 columns
|
||||||
|
- [x] Surface backup/cleanup cron logs in the admin system log viewer: add `backup` + `cleanup` channels to SystemController::LOG_FILES (date-in-filename resolution), friendly empty-state for cron channels, and a `Sauvegardes SQLite` freshness status check in the system status grid
|
||||||
|
- [x] Add backup watchdog: email xamxam@erg.be (or notify_email) when SQLite backups go stale — `scripts/backup-watchdog.php` (read-only, state-file anti-flood) + cron entry + justfile deploy wiring
|
||||||
|
- [x] Add Nextcloud WebDAV sync: push latest SQLite snapshot to cloud.erg.school `/XAMXAM-BCK` (PHP curl, reuses SMTP creds, timestamped + keep 7) — `scripts/nextcloud-sync.php` + cron + justfile wiring; extend watchdog to also alert on stale/missing remote copy
|
||||||
|
- [x] Fix backup retention: replace `find -mtime +N` (off-by-one day rounding) with deterministic filename-timestamp pruning; add manual `sync=1` option to `just deploy-check-backup-log`
|
||||||
- [x] Add custom 404 page: render through layout with dedicated not-found.css, register in build, return HTTP 404
|
- [x] Add custom 404 page: render through layout with dedicated not-found.css, register in build, return HTTP 404
|
||||||
- [x] Remove "Mo" option from duration — keep only minutes and pages
|
- [x] Remove "Mo" option from duration — keep only minutes and pages
|
||||||
- [x] Combine pages and minutes as separate fields (both can be set simultaneously)
|
- [x] Combine pages and minutes as separate fields (both can be set simultaneously)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ class SystemController
|
|||||||
'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
|
'admin' => ['label' => 'Admin — actions', 'path' => null, 'json' => true],
|
||||||
'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true],
|
'error' => ['label' => 'Erreurs — application', 'path' => null, 'json' => true],
|
||||||
'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true],
|
'audit' => ['label' => 'Audit — données', 'path' => null, 'json' => true],
|
||||||
|
'backup' => ['label' => 'Sauvegardes — SQLite', 'path' => null, 'json' => false, 'cron' => 'xamxam-backup'],
|
||||||
|
'cleanup' => ['label' => 'Nettoyage — brouillons', 'path' => null, 'json' => false, 'cron' => 'xamxam-cleanup'],
|
||||||
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam-nginx-access.log', 'json' => false],
|
'nginx_access' => ['label' => 'nginx — accès', 'path' => '/var/log/nginx/xamxam-nginx-access.log', 'json' => false],
|
||||||
'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam-nginx-error.log', 'json' => false],
|
'nginx_error' => ['label' => 'nginx — erreurs','path' => '/var/log/nginx/xamxam-nginx-error.log', 'json' => false],
|
||||||
];
|
];
|
||||||
@@ -33,10 +35,11 @@ class SystemController
|
|||||||
/**
|
/**
|
||||||
* Resolve a log file path — app logs live under /var/log/xamxam in
|
* Resolve a log file path — app logs live under /var/log/xamxam in
|
||||||
* production (and storage/logs in dev/cli-server); nginx logs have
|
* production (and storage/logs in dev/cli-server); nginx logs have
|
||||||
* hard-coded paths (only valid in production).
|
* hard-coded paths (only valid in production); cron logs (backup, cleanup)
|
||||||
|
* live directly under /var/log with the date in the filename.
|
||||||
*
|
*
|
||||||
* A $date (YYYY-MM-DD) selects a specific retained daily file for app
|
* A $date (YYYY-MM-DD) selects a specific retained daily file for app and
|
||||||
* channels; when null the most recent file is returned.
|
* cron channels; when null the most recent file is returned.
|
||||||
*/
|
*/
|
||||||
private static function resolveLogPath(string $tab, ?string $date = null): string
|
private static function resolveLogPath(string $tab, ?string $date = null): string
|
||||||
{
|
{
|
||||||
@@ -44,6 +47,25 @@ class SystemController
|
|||||||
if ($def['path'] !== null) {
|
if ($def['path'] !== null) {
|
||||||
return $def['path'];
|
return $def['path'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cron logs: /var/log/xamxam-{cron}-YYYY-MM-DD.log — the date is part
|
||||||
|
// of the filename (set by the cron job), not a Monolog rotation suffix.
|
||||||
|
// These only exist in production; in dev there is no sensible fallback.
|
||||||
|
if (isset($def['cron'])) {
|
||||||
|
$base = '/var/log/' . $def['cron'];
|
||||||
|
if ($date !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
return $base . '-' . $date . '.log';
|
||||||
|
}
|
||||||
|
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
|
||||||
|
if (!empty($dated)) {
|
||||||
|
rsort($dated); // newest first
|
||||||
|
return $dated[0];
|
||||||
|
}
|
||||||
|
// No log yet — return today's expected path so the caller can show
|
||||||
|
// an empty-state (file_exists() will be false).
|
||||||
|
return $base . '-' . date('Y-m-d') . '.log';
|
||||||
|
}
|
||||||
|
|
||||||
// App logs: /var/log/xamxam/xamxam-{channel}.log (production) or
|
// App logs: /var/log/xamxam/xamxam-{channel}.log (production) or
|
||||||
// storage/logs/xamxam-{channel}.log (dev / cli-server). Monolog
|
// storage/logs/xamxam-{channel}.log (dev / cli-server). Monolog
|
||||||
// RotatingFileHandler uses this as base name; the current log is always
|
// RotatingFileHandler uses this as base name; the current log is always
|
||||||
@@ -77,14 +99,16 @@ class SystemController
|
|||||||
public static function listLogDates(string $tab): array
|
public static function listLogDates(string $tab): array
|
||||||
{
|
{
|
||||||
$def = self::LOG_FILES[$tab];
|
$def = self::LOG_FILES[$tab];
|
||||||
if (($def['json'] ?? false) !== true) {
|
if (isset($def['cron'])) {
|
||||||
|
$base = '/var/log/' . $def['cron'];
|
||||||
|
} elseif (($def['json'] ?? false) === true) {
|
||||||
|
$dir = php_sapi_name() === 'cli-server'
|
||||||
|
? APP_ROOT . '/storage/logs'
|
||||||
|
: '/var/log/xamxam';
|
||||||
|
$base = $dir . '/xamxam-' . $tab;
|
||||||
|
} else {
|
||||||
return []; // nginx logs are fixed-name, not daily
|
return []; // nginx logs are fixed-name, not daily
|
||||||
}
|
}
|
||||||
|
|
||||||
$dir = php_sapi_name() === 'cli-server'
|
|
||||||
? APP_ROOT . '/storage/logs'
|
|
||||||
: '/var/log/xamxam';
|
|
||||||
$base = $dir . '/xamxam-' . $tab;
|
|
||||||
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
|
$dated = glob($base . '-20[0-9][0-9]-[0-9][0-9]-[0-9][0-9].log');
|
||||||
if (empty($dated)) {
|
if (empty($dated)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -229,12 +253,12 @@ class SystemController
|
|||||||
// App logs are rotated by Monolog; a missing file just means no
|
// App logs are rotated by Monolog; a missing file just means no
|
||||||
// events have been logged yet. Show a friendly empty-state message
|
// events have been logged yet. Show a friendly empty-state message
|
||||||
// instead of a scary "fichier introuvable" error.
|
// instead of a scary "fichier introuvable" error.
|
||||||
if ($isJson) {
|
if ($isJson || isset(self::LOG_FILES[$tab]['cron'])) {
|
||||||
return [
|
return [
|
||||||
'lines' => [],
|
'lines' => [],
|
||||||
'error' => null,
|
'error' => null,
|
||||||
'meta' => null,
|
'meta' => null,
|
||||||
'isJson' => true,
|
'isJson' => $isJson,
|
||||||
'notYet' => true,
|
'notYet' => true,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -536,6 +560,9 @@ class SystemController
|
|||||||
: 'Non accessible en écriture',
|
: 'Non accessible en écriture',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// SQLite backups — surface staleness so admins catch a broken cron.
|
||||||
|
$checks['backup'] = $this->backupStatus();
|
||||||
|
|
||||||
// Maintenance mode
|
// Maintenance mode
|
||||||
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
|
$maintenanceOn = file_exists(APP_ROOT . '/storage/maintenance.flag');
|
||||||
$checks['maintenance'] = [
|
$checks['maintenance'] = [
|
||||||
@@ -547,6 +574,73 @@ class SystemController
|
|||||||
return $checks;
|
return $checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the freshness of SQLite backups in /var/backups/xamxam.
|
||||||
|
*
|
||||||
|
* Returns a status-check entry suitable for the system page. The hourly
|
||||||
|
* cron should produce a fresh snapshot at least every ~2 hours, so we
|
||||||
|
* flag a warning past that and an error past a full day.
|
||||||
|
*/
|
||||||
|
private function backupStatus(): array
|
||||||
|
{
|
||||||
|
$dir = '/var/backups/xamxam';
|
||||||
|
$label = 'Sauvegardes SQLite';
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
// In dev (cli-server / local) the backup cron is not set up, so a
|
||||||
|
// missing directory is expected — show inactive rather than failed.
|
||||||
|
$isDev = php_sapi_name() === 'cli-server';
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'status' => $isDev ? 'inactive' : 'failed',
|
||||||
|
'detail' => $isDev
|
||||||
|
? 'Cron de sauvegarde non configuré (environnement de dev)'
|
||||||
|
: 'Dossier /var/backups/xamxam introuvable — cron non déployé ?',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = glob($dir . '/db-*.db.gz');
|
||||||
|
if (empty($files)) {
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'status' => 'warn',
|
||||||
|
'detail' => 'Aucune sauvegarde trouvée dans /var/backups/xamxam',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest backup by mtime
|
||||||
|
$newest = null;
|
||||||
|
$newestMtime = 0;
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$m = filemtime($f);
|
||||||
|
if ($m > $newestMtime) {
|
||||||
|
$newestMtime = $m;
|
||||||
|
$newest = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$ageSec = time() - $newestMtime;
|
||||||
|
$ageHours = (int) round($ageSec / 3600.0);
|
||||||
|
$count = count($files);
|
||||||
|
$human = $ageHours < 1
|
||||||
|
? 'moins d\'une heure'
|
||||||
|
: ($ageHours === 1 ? '1 heure' : "$ageHours heures");
|
||||||
|
|
||||||
|
if ($ageSec > 86400) {
|
||||||
|
$status = 'failed';
|
||||||
|
} elseif ($ageSec > 7200) {
|
||||||
|
$status = 'warn';
|
||||||
|
} else {
|
||||||
|
$status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'status' => $status,
|
||||||
|
'detail' => "$count fichier(s) — dernière il y a $human",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the tail of a log file, newest-first. Returns null on error.
|
* Read the tail of a log file, newest-first. Returns null on error.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,8 +72,16 @@
|
|||||||
|
|
||||||
<?php elseif (!empty($notYet)): ?>
|
<?php elseif (!empty($notYet)): ?>
|
||||||
<div class="log-empty">
|
<div class="log-empty">
|
||||||
Aucune entrée pour le moment.<br>
|
<?php if (isset(SystemController::LOG_FILES[$activeTab]['cron'])): ?>
|
||||||
<span class="log-unavail-path">Le journal sera créé automatiquement au premier événement.</span>
|
Aucun journal de cette tâche pour le moment.<br>
|
||||||
|
<span class="log-unavail-path">
|
||||||
|
Vérifiez que la tâche cron est installée (<code>just deploy-backup-cron</code> / <code>just deploy-cleanup-cron</code>)
|
||||||
|
et qu'elle s'est bien exécutée. Le journal apparaîtra automatiquement à la prochaine exécution.
|
||||||
|
</span>
|
||||||
|
<?php else: ?>
|
||||||
|
Aucune entrée pour le moment.<br>
|
||||||
|
<span class="log-unavail-path">Le journal sera créé automatiquement au premier événement.</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php elseif (empty($logLines)): ?>
|
<?php elseif (empty($logLines)): ?>
|
||||||
|
|||||||
@@ -6,3 +6,9 @@
|
|||||||
|
|
||||||
# Daily snapshot at 2am — kept 90 days
|
# Daily snapshot at 2am — kept 90 days
|
||||||
0 2 * * * www-data RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
0 2 * * * www-data RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
|
||||||
|
# Push the latest snapshot to Nextcloud WebDAV (daily, after the 2am snapshot)
|
||||||
|
20 2 * * * www-data php /usr/local/bin/nextcloud-sync.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
|
||||||
|
# Backup watchdog — email xamxam@erg.be when backups go stale (runs 15 min after the hourly snapshot)
|
||||||
|
15 * * * * www-data php /usr/local/bin/backup-watchdog.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ This directory mixes **current reference** docs, **proposals/plans**, and
|
|||||||
| [database.md](database.md) | SQLite schema, migrations, tables, common ops |
|
| [database.md](database.md) | SQLite schema, migrations, tables, common ops |
|
||||||
| [search.md](search.md) | `/search` and `/repertoire` behaviour |
|
| [search.md](search.md) | `/search` and `/repertoire` behaviour |
|
||||||
| [export.md](export.md) | CSV / DB / files export + full restore procedure |
|
| [export.md](export.md) | CSV / DB / files export + full restore procedure |
|
||||||
|
| [nextcloud-sync.md](nextcloud-sync.md) | Off-site SQLite snapshot sync to Nextcloud WebDAV |
|
||||||
| [import.md](import.md) | CSV import format + behaviour |
|
| [import.md](import.md) | CSV import format + behaviour |
|
||||||
| [security.md](security.md) | Current security posture |
|
| [security.md](security.md) | Current security posture |
|
||||||
| [file-uploads.md](file-uploads.md) | Upload surfaces, types, storage layout |
|
| [file-uploads.md](file-uploads.md) | Upload surfaces, types, storage layout |
|
||||||
|
|||||||
+9
-19
@@ -162,28 +162,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — Remote Sync *(for later)*
|
## Phase 5 — Remote Sync *(implemented — see [nextcloud-sync.md](nextcloud-sync.md))*
|
||||||
|
|
||||||
**Goal:** Push backups off the VM to a remote destination so a disk failure or VM loss doesn't take your history with it.
|
**Goal:** Push backups off the VM to a remote destination so a disk failure or VM loss doesn't take your history with it.
|
||||||
|
|
||||||
- [ ] Choose a remote destination (Backblaze B2, S3, SFTP, etc.)
|
Implemented via a PHP WebDAV sync to Nextcloud (`cloud.erg.school`), reusing the SMTP credentials — see [nextcloud-sync.md](nextcloud-sync.md) for the full reference. The checklist below is superseded by that doc.
|
||||||
- [ ] Install and configure rclone:
|
|
||||||
```bash
|
- [x] Choose a remote destination — **Nextcloud WebDAV** (`/XAMXAM-BCK`)
|
||||||
apt install rclone
|
- [x] Transport — **PHP `curl`**, not rclone (reuses SMTP credentials, no extra binary)
|
||||||
rclone config # set up a remote, name it "mybackups"
|
- [x] Add remote sync separate from the backup script — `scripts/nextcloud-sync.php` (daily cron, 20 min after the 02:00 snapshot)
|
||||||
```
|
- [x] Remote retention — keep last 7 snapshots (`REMOTE_KEEP`)
|
||||||
- [ ] Add remote sync to the backup script after the `gzip` step:
|
- [x] Test a full restore from remote — restore procedure documented in [nextcloud-sync.md](nextcloud-sync.md)
|
||||||
```bash
|
- [x] Monitoring — `backup-watchdog.php` alerts on stale/missing remote copy
|
||||||
rclone copy "$BACKUP_FILE" mybackups:myapp-backups/
|
|
||||||
```
|
|
||||||
- [ ] Enable versioning on the remote bucket (B2/S3) so even remote overwrites are recoverable
|
|
||||||
- [ ] Test a full restore from remote:
|
|
||||||
```bash
|
|
||||||
rclone copy mybackups:myapp-backups/db-<timestamp>.db.gz /tmp/
|
|
||||||
gunzip /tmp/db-<timestamp>.db.gz
|
|
||||||
sqlite3 /tmp/db-<timestamp>.db ".tables"
|
|
||||||
```
|
|
||||||
- [ ] (Optional) Set up a separate cron to prune remote copies older than 6 months
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ gzipped to `/var/backups/xamxam/` on the server.
|
|||||||
Retention: hourly backups kept 30 days, nightly (02:00) backups kept 90 days.
|
Retention: hourly backups kept 30 days, nightly (02:00) backups kept 90 days.
|
||||||
Backup files: `/var/backups/xamxam/db-<timestamp>.db.gz`.
|
Backup files: `/var/backups/xamxam/db-<timestamp>.db.gz`.
|
||||||
|
|
||||||
|
Off-site copies are synced daily to Nextcloud WebDAV — see
|
||||||
|
[nextcloud-sync.md](nextcloud-sync.md).
|
||||||
|
|
||||||
Draft cleanup is handled by a separate cron (`/etc/cron.d/xamxam-cleanup`),
|
Draft cleanup is handled by a separate cron (`/etc/cron.d/xamxam-cleanup`),
|
||||||
installed via `just deploy-cleanup-cron`, logging to
|
installed via `just deploy-cleanup-cron`, logging to
|
||||||
`/var/log/xamxam-cleanup-YYYY-MM-DD.log`. Verify with `just deploy-check-cleanup-log`.
|
`/var/log/xamxam-cleanup-YYYY-MM-DD.log`. Verify with `just deploy-check-cleanup-log`.
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Nextcloud off-site backup sync
|
||||||
|
|
||||||
|
XAMXAM pushes its latest SQLite snapshot to a Nextcloud folder so that a
|
||||||
|
server/disk failure doesn't take the backup history with it. This is the
|
||||||
|
implementation of *Phase 5 — Remote Sync* from [backup-plan.md](backup-plan.md).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Destination** | `https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK` |
|
||||||
|
| **Protocol** | WebDAV (HTTP `PUT` / `PROPFIND` / `DELETE`) |
|
||||||
|
| **Credentials** | Reused from `smtp_settings` (`xamxam@erg.be`), decrypted via `Crypto` |
|
||||||
|
| **Transport** | PHP `curl` extension — no `rclone`, no shell `curl` |
|
||||||
|
| **What is uploaded** | The newest `db-*.db.gz` snapshot from `/var/backups/xamxam` |
|
||||||
|
| **Remote filename** | `xamxam-db-<timestamp>.db.gz` (timestamped, not overwritten) |
|
||||||
|
| **Remote retention** | Last **7** snapshots (configurable via `REMOTE_KEEP`) |
|
||||||
|
| **Cadence** | Daily, 20 min after the 02:00 snapshot |
|
||||||
|
|
||||||
|
The uploads are **SQLite snapshots only** — the CSV and files-ZIP exports
|
||||||
|
(see [export.md](export.md)) are *not* pushed. This keeps Nextcloud usage
|
||||||
|
minimal; the full export set remains available on-demand from the admin panel.
|
||||||
|
|
||||||
|
### Sizing
|
||||||
|
|
||||||
|
As of writing, the live DB is ~2.5 MB and compresses to ~475 KB. Seven daily
|
||||||
|
snapshots are therefore ~3.3 MB — a negligible fraction of the 10 GB Nextcloud
|
||||||
|
quota for `xamxam@erg.be`. If more history is desired, raise `REMOTE_KEEP` in
|
||||||
|
the cron line; there is ample headroom.
|
||||||
|
|
||||||
|
## Script: `scripts/nextcloud-sync.php`
|
||||||
|
|
||||||
|
CLI script (deployed to `/usr/local/bin/nextcloud-sync.php`), run by cron.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Resolve `APP_ROOT` (`/var/www/xamxam` in prod, `app/` in dev) and load the
|
||||||
|
composer autoloader.
|
||||||
|
2. No-op `DatabaseMigrations` (read-only — the sync must never mutate the DB).
|
||||||
|
3. Load SMTP credentials via `SmtpRelay::getSettings()` (decrypts the password).
|
||||||
|
4. Find the newest `db-*.db.gz` in `/var/backups/xamxam`.
|
||||||
|
5. `PUT` it to the WebDAV path (streaming upload, memory-safe).
|
||||||
|
6. `PROPFIND` the uploaded file and verify its size matches the local file
|
||||||
|
(integrity check — a failed sync is detected, not assumed).
|
||||||
|
7. `PROPFIND` the folder, list `xamxam-db-*.db.gz`, and `DELETE` the oldest
|
||||||
|
beyond `REMOTE_KEEP` (`7` by default).
|
||||||
|
|
||||||
|
Exit code `0` on success, `1` on any failure (logged to
|
||||||
|
`/var/log/xamxam-backup-YYYY-MM-DD.log`). A sync failure never blocks the
|
||||||
|
local backup — the two jobs are separate cron entries.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `REMOTE_KEEP` | `7` | Number of remote snapshots to retain |
|
||||||
|
|
||||||
|
## Cron wiring
|
||||||
|
|
||||||
|
In `/etc/cron.d/xamxam-backup` (deployed from `deploy/xamxam-backup.cron`):
|
||||||
|
|
||||||
|
```
|
||||||
|
# Daily snapshot at 2am — kept 90 days
|
||||||
|
0 2 * * * www-data RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
|
||||||
|
# Push the latest snapshot to Nextcloud WebDAV (daily, after the 2am snapshot)
|
||||||
|
20 2 * * * www-data php /usr/local/bin/nextcloud-sync.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
The sync runs as `www-data`, so it shares the file permissions of the
|
||||||
|
backup script (`/var/backups/xamxam` is `www-data:www-data`). The `.env`
|
||||||
|
holding the crypto key is `640 www-data:xamxam` — readable by `www-data`, so
|
||||||
|
`Crypto::decrypt` works from the CLI.
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
The backup watchdog ([`scripts/backup-watchdog.php`](../scripts/backup-watchdog.php))
|
||||||
|
now checks **both** the local snapshots and the Nextcloud copy:
|
||||||
|
|
||||||
|
- **Local** — newest `db-*.db.gz` older than 2 h → alert.
|
||||||
|
- **Remote** — newest `xamxam-db-*.db.gz` older than 48 h (or none found) → alert.
|
||||||
|
|
||||||
|
Both feed the same anti-flood email to `xamxam@erg.be` (or the configured
|
||||||
|
`notify_email`). A single alert can report either or both issues.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read the backup folder (expect 207 Multi-Status)
|
||||||
|
curl -sS -u 'xamxam@erg.be:<password>' -X PROPFIND --head \
|
||||||
|
'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK' \
|
||||||
|
-o /dev/null -w '%{http_code}\n'
|
||||||
|
|
||||||
|
# Upload a test file (expect 201), then delete it (expect 204)
|
||||||
|
curl -sS -u 'xamxam@erg.be:<password>' -X PUT --data-binary test \
|
||||||
|
'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/_write_test.txt' \
|
||||||
|
-o /dev/null -w '%{http_code}\n'
|
||||||
|
curl -sS -u 'xamxam@erg.be:<password>' -X DELETE \
|
||||||
|
'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/_write_test.txt' \
|
||||||
|
-o /dev/null -w '%{http_code}\n'
|
||||||
|
|
||||||
|
# Run the sync manually (server only)
|
||||||
|
sudo -u www-data php /usr/local/bin/nextcloud-sync.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restoring from Nextcloud
|
||||||
|
|
||||||
|
The remote snapshot is the same `.db.gz` the local backup produces, so
|
||||||
|
restoring follows the standard procedure in [deployment.md](deployment.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the snapshot
|
||||||
|
curl -sS -u 'xamxam@erg.be:<password>' -O \
|
||||||
|
'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK/xamxam-db-<timestamp>.db.gz'
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
sudo systemctl stop nginx
|
||||||
|
gunzip -c xamxam-db-<timestamp>.db.gz > /var/www/xamxam/storage/xamxam.db
|
||||||
|
chown www-data:www-data /var/www/xamxam/storage/xamxam.db
|
||||||
|
chmod 660 /var/www/xamxam/storage/xamxam.db
|
||||||
|
sudo systemctl start nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Credentials are read from the DB (`smtp_settings`) and **never** logged or
|
||||||
|
echoed; only the resulting success/failure messages are written to the log.
|
||||||
|
- The Nextcloud password is the same LDAP/SSO-backed `xamxam@erg.be`
|
||||||
|
credential used for SMTP — the WebDAV Basic-auth endpoint accepts it directly
|
||||||
|
(verified live: `PUT` → 201, `PROPFIND` → 207, `DELETE` → 204).
|
||||||
|
- `CURLOPT_SSL_VERIFYPEER` / `CURLOPT_SSL_VERIFYHOST` are enabled (certificate
|
||||||
|
chain is validated).
|
||||||
|
- The sync is one-way and additive-then-pruned: it never deletes a snapshot
|
||||||
|
until a newer one has been confirmed uploaded.
|
||||||
@@ -359,6 +359,14 @@ deploy-backup-script:
|
|||||||
rsync -v scripts/backup-sqlite.sh xamxam:/tmp/backup-sqlite.sh
|
rsync -v scripts/backup-sqlite.sh xamxam:/tmp/backup-sqlite.sh
|
||||||
ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/backup-sqlite.sh /usr/local/bin/backup-sqlite.sh && rm -f /tmp/backup-sqlite.sh"
|
ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/backup-sqlite.sh /usr/local/bin/backup-sqlite.sh && rm -f /tmp/backup-sqlite.sh"
|
||||||
@echo "✅ backup-sqlite.sh installed to /usr/local/bin/"
|
@echo "✅ backup-sqlite.sh installed to /usr/local/bin/"
|
||||||
|
@echo "📋 Deploying backup watchdog…"
|
||||||
|
rsync -v scripts/backup-watchdog.php xamxam:/tmp/backup-watchdog.php
|
||||||
|
ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/backup-watchdog.php /usr/local/bin/backup-watchdog.php && rm -f /tmp/backup-watchdog.php"
|
||||||
|
@echo "✅ backup-watchdog.php installed to /usr/local/bin/"
|
||||||
|
@echo "📋 Deploying Nextcloud sync…"
|
||||||
|
rsync -v scripts/nextcloud-sync.php xamxam:/tmp/nextcloud-sync.php
|
||||||
|
ssh -t xamxam "sudo install -o root -g root -m 755 /tmp/nextcloud-sync.php /usr/local/bin/nextcloud-sync.php && rm -f /tmp/nextcloud-sync.php"
|
||||||
|
@echo "✅ nextcloud-sync.php installed to /usr/local/bin/"
|
||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
deploy-backup-cron:
|
deploy-backup-cron:
|
||||||
@@ -382,8 +390,22 @@ deploy-backup: deploy-backup-script deploy-backup-cron
|
|||||||
# One-shot: deploy backup script + install cron jobs + set up directories.
|
# One-shot: deploy backup script + install cron jobs + set up directories.
|
||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
deploy-check-backup-log:
|
deploy-check-backup-log sync='':
|
||||||
|
# Tail the backup log, then optionally run the Nextcloud sync manually.
|
||||||
|
# Usage: `just deploy-check-backup-log` (tail only) or `just deploy-check-backup-log 1` (tail + sync).
|
||||||
ssh xamxam "tail -20 /var/log/xamxam-backup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
|
ssh xamxam "tail -20 /var/log/xamxam-backup-\$(date +%Y-%m-%d).log 2>/dev/null || echo '(log file empty or missing — will be created on first cron run)'"
|
||||||
|
# Accept both `1` and `sync=1` for convenience; anything non-empty other than
|
||||||
|
# the literal "sync=1" positional string is also treated as a truthy request.
|
||||||
|
@case "{{sync}}" in \
|
||||||
|
1|sync|sync=1) \
|
||||||
|
echo "===== Running manual Nextcloud sync ====="; \
|
||||||
|
ssh -t xamxam "sudo -u www-data php /usr/local/bin/nextcloud-sync.php; echo exit=\$?";; \
|
||||||
|
''|0|no) \
|
||||||
|
echo "(sync skipped — pass 1 to also run it)";; \
|
||||||
|
*) \
|
||||||
|
echo "===== Running manual Nextcloud sync ====="; \
|
||||||
|
ssh -t xamxam "sudo -u www-data php /usr/local/bin/nextcloud-sync.php; echo exit=\$?";; \
|
||||||
|
esac
|
||||||
|
|
||||||
[group('deploy')]
|
[group('deploy')]
|
||||||
deploy-list-backups:
|
deploy-list-backups:
|
||||||
|
|||||||
@@ -32,7 +32,23 @@ sqlite3 "$DB_PATH" ".backup $TMP_SNAPSHOT"
|
|||||||
gzip -c "$TMP_SNAPSHOT" > "$BACKUP_FILE"
|
gzip -c "$TMP_SNAPSHOT" > "$BACKUP_FILE"
|
||||||
rm -f "$TMP_SNAPSHOT"
|
rm -f "$TMP_SNAPSHOT"
|
||||||
|
|
||||||
# Prune old backups
|
# Prune old backups.
|
||||||
find "$BACKUP_DIR" -name "*.db.gz" -mtime "+${RETENTION_DAYS}" -delete 2>/dev/null || true
|
# Use filename timestamps (deterministic) rather than -mtime, which has
|
||||||
|
# day-rounding ambiguity (-mtime +N means N+1 days). Files are named
|
||||||
|
# db-YYYY-MM-DDTHH-MM-SS.db.gz, so we delete anything whose date is more
|
||||||
|
# than RETENTION_DAYS in the past.
|
||||||
|
RETENTION_SECONDS=$((RETENTION_DAYS * 86400))
|
||||||
|
NOW_EPOCH=$(date +%s)
|
||||||
|
for f in "$BACKUP_DIR"/db-*.db.gz; do
|
||||||
|
[ -e "$f" ] || continue
|
||||||
|
base=$(basename "$f" .db.gz) # db-YYYY-MM-DDTHH-MM-SS
|
||||||
|
stamp=${base#db-} # YYYY-MM-DDTHH-MM-SS
|
||||||
|
# Reformat YYYY-MM-DDTHH-MM-SS → YYYY-MM-DD HH:MM:SS for date(1).
|
||||||
|
named=$(echo "$stamp" | sed 's/T/ /; s/-/:/3g') || continue
|
||||||
|
ts=$(date -d "$named" +%s 2>/dev/null) || continue
|
||||||
|
if [ $((NOW_EPOCH - ts)) -gt $RETENTION_SECONDS ]; then
|
||||||
|
rm -f "$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup written: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup written: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* backup-watchdog.php — alert by email when SQLite backups go stale.
|
||||||
|
*
|
||||||
|
* Checks the newest db-*.db.gz in /var/backups/xamxam and, if it is older
|
||||||
|
* than the staleness threshold, sends an alert via the app's SMTP relay to
|
||||||
|
* xamxam@erg.be (or the configured notify_email). A state file prevents
|
||||||
|
* repeated alerts for the same incident: an email is only sent on the
|
||||||
|
* transition fresh → stale, not on every run while backups remain down.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php scripts/backup-watchdog.php # 2h staleness threshold
|
||||||
|
* STALE_AFTER_SECONDS=86400 php scripts/backup-watchdog.php
|
||||||
|
*
|
||||||
|
* Expected to run from cron (after each backup cron), e.g.:
|
||||||
|
* 15 * * * * www-data php /usr/local/bin/backup-watchdog.php >> /var/log/xamxam-backup-$(date +\%Y-\%m-\%d).log 2>&1
|
||||||
|
*
|
||||||
|
* Exit codes: 0 ok (or alert sent), 1 error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 STATE_FILE = BACKUP_DIR . '/.last_backup_alert';
|
||||||
|
const ALERT_EMAIL = 'xamxam@erg.be';
|
||||||
|
const WEBDAV_BASE = 'https://cloud.erg.school/remote.php/dav/files/xamxam%40erg.be/XAMXAM-BCK';
|
||||||
|
const REMOTE_PREFIX = 'xamxam-db-';
|
||||||
|
const REMOTE_STALE_SECONDS = 172800; // 48h — daily sync, allow one missed day
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// check backup freshness. If DatabaseMigrations isn't loaded, no-op it so the
|
||||||
|
// Database constructor only opens a PDO connection.
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Staleness threshold: default 2 hours (matches the admin system-page status).
|
||||||
|
$staleAfterSeconds = (int) (getenv('STALE_AFTER_SECONDS') ?: 7200);
|
||||||
|
if ($staleAfterSeconds < 60) {
|
||||||
|
$staleAfterSeconds = 7200;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newestMtime = 0;
|
||||||
|
$newestName = null;
|
||||||
|
$files = glob(BACKUP_DIR . '/db-*.db.gz') ?: [];
|
||||||
|
foreach ($files as $f) {
|
||||||
|
$m = @filemtime($f);
|
||||||
|
if ($m !== false && $m > $newestMtime) {
|
||||||
|
$newestMtime = $m;
|
||||||
|
$newestName = $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$age = $newestMtime > 0 ? ($now - $newestMtime) : null;
|
||||||
|
$localDown = ($age === null) || ($age > $staleAfterSeconds);
|
||||||
|
|
||||||
|
// Remote (Nextcloud) staleness — only relevant if we have credentials.
|
||||||
|
// PROPFIND the backup folder and find the newest snapshot's last-modified time.
|
||||||
|
$remoteDown = false;
|
||||||
|
$remoteStatus = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = new Database();
|
||||||
|
$smtp = SmtpRelay::getSettings($db);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Cannot read credentials — skip the remote check rather than fail the run.
|
||||||
|
$db = null;
|
||||||
|
$smtp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db !== null && !empty($smtp['username']) && !empty($smtp['password'])) {
|
||||||
|
$remote = remoteFreshness($smtp['username'], $smtp['password']);
|
||||||
|
if ($remote['oldest_age'] === null) {
|
||||||
|
$remoteDown = true;
|
||||||
|
$remoteStatus = 'aucune copie distante trouvée dans XAMXAM-BCK';
|
||||||
|
} elseif ($remote['oldest_age'] > REMOTE_STALE_SECONDS) {
|
||||||
|
$remoteDown = true;
|
||||||
|
$hours = (int) round($remote['oldest_age'] / 3600.0);
|
||||||
|
$remoteStatus = "la copie Nextcloud la plus récente date d'il y a {$hours} h";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No creds — can't check; don't alarm about remote.
|
||||||
|
$remoteDown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDown = $localDown || $remoteDown;
|
||||||
|
|
||||||
|
if (!$isDown) {
|
||||||
|
// Backups are fresh. Clear any prior alert marker so a future outage
|
||||||
|
// triggers a fresh email.
|
||||||
|
if (is_file(STATE_FILE)) {
|
||||||
|
@unlink(STATE_FILE);
|
||||||
|
}
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale/absent — avoid flooding: only email once per incident.
|
||||||
|
if (is_file(STATE_FILE)) {
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose the alert.
|
||||||
|
$issues = [];
|
||||||
|
if ($localDown) {
|
||||||
|
if ($age === null) {
|
||||||
|
$issues[] = 'aucune sauvegarde locale trouvée dans ' . BACKUP_DIR;
|
||||||
|
} else {
|
||||||
|
$hours = (int) round($age / 3600.0);
|
||||||
|
$issues[] = "sauvegarde locale la plus récente il y a {$hours} h"
|
||||||
|
. ($newestName ? ' (' . basename($newestName) . ')' : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($remoteDown) {
|
||||||
|
$issues[] = $remoteStatus;
|
||||||
|
}
|
||||||
|
$status = implode(' ; ', $issues);
|
||||||
|
|
||||||
|
$subject = '⚠ XAMXAM — sauvegarde SQLite inactive';
|
||||||
|
$remoteNote = $remoteDown
|
||||||
|
? "\n <p>Copie distante (Nextcloud) : <strong>en retard</strong> — {$remoteStatus}.</p>"
|
||||||
|
: '';
|
||||||
|
$body = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="UTF-8"></head>
|
||||||
|
<body style="font-family:system-ui,Arial,sans-serif;line-height:1.6;color:#333">
|
||||||
|
<div style="max-width:600px;margin:0 auto;padding:20px">
|
||||||
|
<h2 style="color:#c53030">Sauvegarde SQLite inactive</h2>
|
||||||
|
<p>Le système de sauvegarde XAMXAM semble en panne : {$status}.</p>{$remoteNote}
|
||||||
|
<p>Veuillez vérifier le cron de sauvegarde et les journaux :</p>
|
||||||
|
<pre style="background:#f7fafc;padding:12px;border-left:4px solid #c53030;overflow-x:auto">tail -50 /var/log/xamxam-backup-$(date +%Y-%m-%d).log
|
||||||
|
ls -lth /var/backups/xamxam/</pre>
|
||||||
|
<p style="margin-top:20px;color:#666;font-size:.9em">
|
||||||
|
Cet e-mail est envoyé automatiquement par le moniteur de sauvegarde XAMXAM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
$plain = "Sauvegarde SQLite inactive — {$status}.\n\n"
|
||||||
|
. "Vérifiez : tail -50 /var/log/xamxam-backup-$(date +%Y-%m-%d).log\n"
|
||||||
|
. "et : ls -lth /var/backups/xamxam/\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($db === null) {
|
||||||
|
// Cannot read SMTP credentials to send the alert — log and exit.
|
||||||
|
error_log('[backup-watchdog] Backup stale but SMTP creds unreadable; alert not sent. ' . $status);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the configured admin notification address; fall back to the
|
||||||
|
// hard-coded XAMXAM account so this alert is never silently dropped.
|
||||||
|
$to = SmtpRelay::getNotifyEmail($db);
|
||||||
|
if ($to === '') {
|
||||||
|
$to = ALERT_EMAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
SmtpRelay::send($db, $to, $subject, $body, $plain);
|
||||||
|
|
||||||
|
// Persist the alert marker only after a successful send, so a transient
|
||||||
|
// SMTP failure does not suppress the next run's alert.
|
||||||
|
@file_put_contents(STATE_FILE, $now . "\n");
|
||||||
|
error_log('[backup-watchdog] Alert sent to ' . $to . ' — ' . $status);
|
||||||
|
exit(0);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[backup-watchdog] Alert delivery failed: ' . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PROPFIND the Nextcloud backup folder and return the newest snapshot's age.
|
||||||
|
*
|
||||||
|
* @return array{oldest_age:?int, count:int} oldest_age in seconds (null if none)
|
||||||
|
*/
|
||||||
|
function remoteFreshness(string $user, string $pass): array
|
||||||
|
{
|
||||||
|
$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);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($body === false) {
|
||||||
|
return ['oldest_age' => null, 'count' => 0]; // unreachable → treat as stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the newest last-modified timestamp among our snapshot files.
|
||||||
|
$newest = 0;
|
||||||
|
$count = 0;
|
||||||
|
// Match <d:href>…</d:href> and adjacent lastmodified, or fall back to
|
||||||
|
// parsing <d:getlastmodified> entries in order.
|
||||||
|
preg_match_all(
|
||||||
|
'/<d:href>([^<]*' . preg_quote(REMOTE_PREFIX, '/') . '[^<]*\.db\.gz)<\/d:href>.*?<d:getlastmodified>([^<]+)<\/d:getlastmodified>/is',
|
||||||
|
$body,
|
||||||
|
$m,
|
||||||
|
PREG_SET_ORDER
|
||||||
|
);
|
||||||
|
foreach ($m as $entry) {
|
||||||
|
$href = rawurldecode(basename(rtrim($entry[1], '/')));
|
||||||
|
if (!str_starts_with($href, REMOTE_PREFIX) || !str_ends_with($href, '.db.gz')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$count++;
|
||||||
|
$ts = strtotime($entry[2]);
|
||||||
|
if ($ts !== false && $ts > $newest) {
|
||||||
|
$newest = $ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newest === 0) {
|
||||||
|
return ['oldest_age' => null, 'count' => 0]; // none found
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['oldest_age' => time() - $newest, 'count' => $count];
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
#!/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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user