mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- tag-search: add minTags/required params, counter shows red if < 3, accent if ≥ 3 - form.php: pass minTags=3 for partage mode keywords - checkbox-list: support labelHtml for raw HTML label with targetable asterisk span - language-autre-fragment: OOB swap updates #languages-required-asterisk when autre pills change - language-search: client-side update #languages-required-asterisk on pill add/remove - contenus.php: replace 3 form+submit-button fieldsets with HTMX auto-save checkboxes - settings.php: detect HX-Request header, return OOB CSRF token updates, skip redirect
199 lines
6.7 KiB
Markdown
199 lines
6.7 KiB
Markdown
# SQLite Backup & Data Integrity Plan
|
|
|
|
## Status Legend
|
|
- `[ ]` To do
|
|
- `[x]` Done
|
|
- `[~]` Partial / needs review
|
|
|
|
---
|
|
|
|
## Phase 1 — WAL Mode
|
|
|
|
**Goal:** Ensure SQLite uses Write-Ahead Logging for safe concurrent reads and hot backups.
|
|
|
|
- [ ] Connect to the DB and verify WAL is active:
|
|
```bash
|
|
sqlite3 /path/to/your.db "PRAGMA journal_mode;"
|
|
# Expected output: wal
|
|
```
|
|
- [ ] If not `wal`, enable it (run once, persists):
|
|
```bash
|
|
sqlite3 /path/to/your.db "PRAGMA journal_mode=WAL;"
|
|
```
|
|
- [ ] Confirm the `-wal` and `-shm` sidecar files exist next to the `.db` file after a write
|
|
- [ ] Make sure nginx/PHP has write access to those sidecar files (same owner as the `.db`)
|
|
|
|
---
|
|
|
|
## Phase 2 — Audit Log
|
|
|
|
**Goal:** Record every INSERT, UPDATE, and DELETE with the actor, timestamp, and a before/after snapshot.
|
|
|
|
### 2.1 — Create the table
|
|
|
|
- [ ] Add the `audit_log` table to the DB:
|
|
```sql
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|
actor TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK(action IN ('INSERT','UPDATE','DELETE')),
|
|
table_name TEXT NOT NULL,
|
|
record_id INTEGER,
|
|
old_data TEXT,
|
|
new_data TEXT
|
|
);
|
|
```
|
|
|
|
### 2.2 — Instrument PHP mutations
|
|
|
|
- [ ] Create a reusable `audit()` helper in PHP that accepts `$db, $actor, $action, $table, $id, $old, $new`
|
|
- [ ] Wrap every **DELETE** in the admin dashboard with `audit()`, capturing the row before deletion
|
|
- [ ] Wrap every **UPDATE** (form submissions + admin edits) with `audit()`, capturing before/after
|
|
- [ ] Wrap **INSERTs** for completeness (new_data only)
|
|
- [ ] Verify by triggering a test delete and querying `SELECT * FROM audit_log ORDER BY id DESC LIMIT 5;`
|
|
|
|
### 2.3 — Protect the audit log
|
|
|
|
- [ ] No UI should expose a "clear audit log" button
|
|
- [ ] The PHP DB user should not have `DELETE` permission on `audit_log` (use a restricted PDO connection for app queries if possible)
|
|
|
|
---
|
|
|
|
## Phase 3 — Soft Deletes
|
|
|
|
**Goal:** Prevent hard DELETEs on critical tables so data is always recoverable instantly. htmx elements that query languages/keywords must continue to work transparently.
|
|
|
|
### 3.1 — Schema changes
|
|
|
|
- [ ] Identify all tables that htmx elements query (e.g. `languages`, `keywords`, any lookup/reference tables)
|
|
- [ ] Add `deleted_at` to each:
|
|
```sql
|
|
ALTER TABLE languages ADD COLUMN deleted_at TEXT DEFAULT NULL;
|
|
ALTER TABLE keywords ADD COLUMN deleted_at TEXT DEFAULT NULL;
|
|
-- repeat for other affected tables
|
|
```
|
|
|
|
### 3.2 — Replace DELETE queries
|
|
|
|
- [ ] Search the codebase for `DELETE FROM languages`, `DELETE FROM keywords`, etc.
|
|
- [ ] Replace each hard DELETE with a soft delete:
|
|
```php
|
|
// Before
|
|
$db->prepare("DELETE FROM languages WHERE id = ?")->execute([$id]);
|
|
|
|
// After
|
|
$db->prepare("UPDATE languages SET deleted_at = datetime('now') WHERE id = ?")
|
|
->execute([$id]);
|
|
```
|
|
- [ ] Do the same in any admin dashboard bulk-delete operations
|
|
|
|
### 3.3 — Filter deleted rows everywhere
|
|
|
|
- [ ] Add `WHERE deleted_at IS NULL` to **every** SELECT that feeds an htmx endpoint:
|
|
```sql
|
|
-- Example
|
|
SELECT * FROM languages WHERE deleted_at IS NULL ORDER BY name;
|
|
SELECT * FROM keywords WHERE deleted_at IS NULL ORDER BY name;
|
|
```
|
|
- [ ] Search for raw `SELECT * FROM languages` and `SELECT * FROM keywords` across all PHP files and patch each one
|
|
- [ ] Test each htmx-driven element (dropdowns, tag lists, autocompletes) to confirm deleted entries no longer appear
|
|
|
|
### 3.4 — Admin: show soft-deleted entries
|
|
|
|
- [ ] Add an admin view that lists soft-deleted rows (`WHERE deleted_at IS NOT NULL`) with a **Restore** button
|
|
- [ ] The restore action sets `deleted_at = NULL`
|
|
|
|
---
|
|
|
|
## Phase 4 — Hourly Snapshots via Cronjob
|
|
|
|
**Goal:** Automatically save compressed, timestamped copies of the DB locally, retained for 30 days.
|
|
|
|
### 4.1 — Create the backup script
|
|
|
|
- [ ] Create `/usr/local/bin/backup-sqlite.sh`:
|
|
```bash
|
|
#!/bin/bash
|
|
DB_PATH="/var/www/myapp/database.db"
|
|
BACKUP_DIR="/var/backups/myapp"
|
|
RETENTION_DAYS="${RETENTION_DAYS:-30}"
|
|
TIMESTAMP=$(date +"%Y-%m-%dT%H-%M-%S")
|
|
BACKUP_FILE="$BACKUP_DIR/db-$TIMESTAMP.db.gz"
|
|
|
|
mkdir -p "$BACKUP_DIR"
|
|
|
|
# Safe hot backup using SQLite's online backup API
|
|
sqlite3 "$DB_PATH" ".backup /tmp/myapp-snapshot.db"
|
|
gzip -c /tmp/myapp-snapshot.db > "$BACKUP_FILE"
|
|
rm /tmp/myapp-snapshot.db
|
|
|
|
# Prune old backups
|
|
find "$BACKUP_DIR" -name "*.db.gz" -mtime +$RETENTION_DAYS -delete
|
|
|
|
echo "[$(date)] Backup written: $BACKUP_FILE"
|
|
```
|
|
- [ ] Make it executable:
|
|
```bash
|
|
chmod +x /usr/local/bin/backup-sqlite.sh
|
|
```
|
|
- [ ] Run it manually once and verify a `.db.gz` file appears in `/var/backups/myapp/`
|
|
- [ ] Test restore by decompressing and opening the snapshot:
|
|
```bash
|
|
gunzip -c /var/backups/myapp/db-<timestamp>.db.gz > /tmp/test-restore.db
|
|
sqlite3 /tmp/test-restore.db ".tables"
|
|
```
|
|
|
|
### 4.2 — Schedule with cron
|
|
|
|
- [ ] Open the crontab:
|
|
```bash
|
|
crontab -e
|
|
```
|
|
- [ ] Add hourly and daily jobs:
|
|
```cron
|
|
# Hourly snapshot — kept 30 days
|
|
0 * * * * /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
|
|
|
# Daily snapshot at 2am — kept 90 days
|
|
0 2 * * * RETENTION_DAYS=90 /usr/local/bin/backup-sqlite.sh >> /var/log/sqlite-backup.log 2>&1
|
|
```
|
|
- [ ] Verify the log after the next hour: `tail -f /var/log/sqlite-backup.log`
|
|
|
|
---
|
|
|
|
## Phase 5 — Remote Sync *(for later)*
|
|
|
|
**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.)
|
|
- [ ] Install and configure rclone:
|
|
```bash
|
|
apt install rclone
|
|
rclone config # set up a remote, name it "mybackups"
|
|
```
|
|
- [ ] Add remote sync to the backup script after the `gzip` step:
|
|
```bash
|
|
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
|
|
|
|
---
|
|
|
|
## Quick Reference — Recovery Scenarios
|
|
|
|
| Scenario | Solution |
|
|
|---|---|
|
|
| Admin accidentally deleted a row | Set `deleted_at = NULL` in the relevant table |
|
|
| User submitted bad data via a form | Query `audit_log` for the `old_data` JSON, restore manually |
|
|
| Bulk accidental delete | Restore from the last hourly snapshot (< 1h data loss max) |
|
|
| VM or disk failure | Pull latest snapshot from remote (Phase 5) |
|
|
| "Who deleted this and when?" | `SELECT * FROM audit_log WHERE table_name='x' AND action='DELETE'` |
|