fix(backup): address code review feedback - accessibility, CSRF, docs, imports

- Move `import os` to top-level in app/views/backup.py
- Fix docstring in BackupRecord model to remove non-existent 'location' field
- Replace browser confirm() dialogs with accessible modal dialog (role=dialog, aria-modal, aria-labelledby)
- Add csrfToken() helper that validates token presence instead of silently falling back to empty string
- Fix aria-live region to remain in DOM (screen-reader friendly) rather than using x-show
- Add Backup & Restore section to docs/ConfigurationGuide.md with retention table
- Add backup env vars to .env.demo with comments

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 22:08:47 +00:00
parent 2dd1ca0197
commit 1877fc0000
5 changed files with 109 additions and 15 deletions
+14
View File
@@ -350,6 +350,20 @@ WEBHOOK_ENABLED=True
UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up UPTIME_KUMA_URL=https://status.example.com/api/push/abcdef123456?status=up
UPTIME_KUMA_PING_INTERVAL=5 UPTIME_KUMA_PING_INTERVAL=5
# Backup & Restore
# Enable automatic scheduled backups (hourly, daily, weekly)
BACKUP_ENABLED=True
# Directory for local backup archives (defaults to <WORKDIR>/backups)
# BACKUP_DIR=/data/backups
# Optional remote destination: s3, dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email
# BACKUP_REMOTE_DESTINATION=s3
# Sub-folder used when uploading backup archives to the remote destination
BACKUP_REMOTE_FOLDER=backups
# Retention: number of snapshots to keep per tier
BACKUP_RETAIN_HOURLY=96 # 4 days of hourly snapshots
BACKUP_RETAIN_DAILY=21 # 3 weeks of daily snapshots
BACKUP_RETAIN_WEEKLY=13 # ~3 months of weekly snapshots
# **Full-Text Search (Meilisearch)** # **Full-Text Search (Meilisearch)**
# URL for the Meilisearch instance. # URL for the Meilisearch instance.
# Default is "http://meilisearch:7700" — the Docker Compose / K8s service name — # Default is "http://meilisearch:7700" — the Docker Compose / K8s service name —
+3 -3
View File
@@ -384,9 +384,9 @@ class BackupRecord(Base):
- ``hourly`` kept for up to 4 days (96 snapshots) - ``hourly`` kept for up to 4 days (96 snapshots)
- ``daily`` kept for up to 3 weeks (21 snapshots) - ``daily`` kept for up to 3 weeks (21 snapshots)
- ``weekly`` kept for up to 13 weeks (≈ 90 days) - ``weekly`` kept for up to 13 weeks (≈ 90 days)
``location`` is ``local`` when the file is stored on-disk under the ``local_path`` is the full filesystem path of the local copy (``None``
configured backup directory, or ``remote`` when it has been uploaded to once pruned). ``remote_destination`` and ``remote_path`` describe the
a storage provider or sent via e-mail. remote copy when one has been uploaded to a storage provider or e-mailed.
""" """
__tablename__ = "backup_records" __tablename__ = "backup_records"
+1 -2
View File
@@ -3,6 +3,7 @@ Backup management dashboard view admin only.
""" """
import logging import logging
import os
from fastapi import Depends, HTTPException, Request, status from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -31,8 +32,6 @@ async def backup_dashboard(request: Request, db: Session = Depends(get_db)):
counts[r.backup_type] += 1 counts[r.backup_type] += 1
# Compute total local size # Compute total local size
import os
total_size = sum(r.size_bytes for r in records if r.local_path and os.path.exists(r.local_path)) total_size = sum(r.size_bytes for r in records if r.local_path and os.path.exists(r.local_path))
return templates.TemplateResponse( return templates.TemplateResponse(
+36
View File
@@ -887,6 +887,33 @@ Configurations are stored in the database and managed through the API (see [API
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure. Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
### Backup & Restore
DocuElevate can automatically back up the SQLite database on a scheduled basis.
Backups are managed from the **Admin → Backup & Restore** dashboard.
| **Variable** | **Description** | **Default** |
|--------------------------------|-----------------------------------------------------------------------------------------------|---------------------|
| `BACKUP_ENABLED` | Enable or disable automatic scheduled backups (`True`/`False`). | `True` |
| `BACKUP_DIR` | Filesystem path where local backup archives are stored. Defaults to `<WORKDIR>/backups`. | *(workdir/backups)* |
| `BACKUP_REMOTE_DESTINATION` | Storage provider to copy backups to. Options: `s3`, `dropbox`, `google_drive`, `onedrive`, `nextcloud`, `webdav`, `ftp`, `sftp`, `email`. Leave empty for local-only storage. | *(empty)* |
| `BACKUP_REMOTE_FOLDER` | Sub-folder / key prefix used when uploading to the remote destination. | `backups` |
| `BACKUP_RETAIN_HOURLY` | Number of hourly snapshots to keep (1 per hour = 96 covers 4 days). | `96` |
| `BACKUP_RETAIN_DAILY` | Number of daily snapshots to keep (21 = 3 weeks). | `21` |
| `BACKUP_RETAIN_WEEKLY` | Number of weekly snapshots to keep (13 ≈ 3 months). | `13` |
**Retention schedule:**
| Tier | Frequency | Default retention | Coverage |
|---------|------------------|-------------------|--------------|
| Hourly | Every hour | 96 snapshots | ~4 days |
| Daily | Daily at 02:00 | 21 snapshots | ~3 weeks |
| Weekly | Sundays at 03:00 | 13 snapshots | ~3 months |
Archives beyond the retention window are automatically pruned after each new backup. The **Clean Up** button on the dashboard applies retention immediately. When a remote destination is configured, remote copies follow the same retention policy.
> **Note:** Backup and restore is currently supported only for SQLite databases.
### Uptime Kuma ### Uptime Kuma
| **Variable** | **Description** | | **Variable** | **Description** |
@@ -1223,6 +1250,15 @@ S3_ACL=private
# Uptime Kuma # Uptime Kuma
UPTIME_KUMA_URL=https://kuma.example.com/api/push/abcde12345?status=up UPTIME_KUMA_URL=https://kuma.example.com/api/push/abcde12345?status=up
UPTIME_KUMA_PING_INTERVAL=5 UPTIME_KUMA_PING_INTERVAL=5
# Backup & Restore
BACKUP_ENABLED=True
BACKUP_DIR=/data/backups
BACKUP_REMOTE_DESTINATION=s3 # or dropbox, google_drive, onedrive, nextcloud, webdav, ftp, sftp, email
BACKUP_REMOTE_FOLDER=backups
BACKUP_RETAIN_HOURLY=96
BACKUP_RETAIN_DAILY=21
BACKUP_RETAIN_WEEKLY=13
``` ```
## Selective Service Configuration ## Selective Service Configuration
+55 -10
View File
@@ -45,13 +45,38 @@
</div> </div>
</div> </div>
<!-- Status flash --> <!-- Status flash (always in DOM; content toggled via aria-live) -->
<div x-show="flashMsg" x-transition aria-live="polite" <div role="alert" aria-live="polite" aria-atomic="true"
:class="flashError ? 'bg-red-100 border-red-500 text-red-700' : 'bg-green-100 border-green-500 text-green-700'" :class="flashMsg ? '' : 'sr-only'"
class="border-l-4 p-4 mb-4 rounded" role="alert"> class="border-l-4 p-4 mb-4 rounded transition-all"
:style="flashMsg ? '' : 'pointer-events:none'"
x-bind:class="flashMsg ? (flashError ? 'bg-red-100 border-red-500 text-red-700' : 'bg-green-100 border-green-500 text-green-700') : 'sr-only'">
<span x-text="flashMsg"></span> <span x-text="flashMsg"></span>
</div> </div>
<!-- Accessible confirm dialog -->
<div x-show="confirmOpen" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
role="dialog" aria-modal="true" :aria-labelledby="'confirmTitle'">
<div class="bg-white rounded-lg shadow-xl max-w-sm w-full p-6"
@keydown.escape.window="confirmOpen = false">
<h2 id="confirmTitle" class="text-lg font-semibold text-gray-900 mb-2">Confirm action</h2>
<p class="text-sm text-gray-600 mb-4" x-text="confirmMsg"></p>
<div class="flex justify-end gap-3">
<button @click="confirmOpen = false; confirmResolve(false)"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 min-h-[44px]">
Cancel
</button>
<button @click="confirmOpen = false; confirmResolve(true)"
type="button"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 min-h-[44px]">
Confirm
</button>
</div>
</div>
</div>
<!-- Config summary --> <!-- Config summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<!-- Backup enabled --> <!-- Backup enabled -->
@@ -262,19 +287,37 @@ function backupDashboard() {
filterType: '', filterType: '',
flashMsg: '', flashMsg: '',
flashError: false, flashError: false,
confirmOpen: false,
confirmMsg: '',
confirmResolve: null,
/** Show a timed status flash message. */
flash(msg, isError = false) { flash(msg, isError = false) {
this.flashMsg = msg; this.flashMsg = msg;
this.flashError = isError; this.flashError = isError;
setTimeout(() => { this.flashMsg = ''; }, 5000); setTimeout(() => { this.flashMsg = ''; }, 5000);
}, },
/** Return CSRF token value, or throw if missing. */
csrfToken() {
const el = document.querySelector('[name=csrf_token]');
if (!el || !el.value) throw new Error('CSRF token missing cannot proceed.');
return el.value;
},
/** Show the accessible confirm dialog and return a Promise<boolean>. */
askConfirm(msg) {
this.confirmMsg = msg;
this.confirmOpen = true;
return new Promise(resolve => { this.confirmResolve = resolve; });
},
async triggerBackup(type) { async triggerBackup(type) {
this.triggering = true; this.triggering = true;
try { try {
const resp = await fetch(`/api/admin/backup/create?backup_type=${type}`, { const resp = await fetch(`/api/admin/backup/create?backup_type=${type}`, {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' }, headers: { 'X-CSRF-Token': this.csrfToken() },
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
@@ -296,7 +339,7 @@ function backupDashboard() {
try { try {
const resp = await fetch('/api/admin/backup/cleanup', { const resp = await fetch('/api/admin/backup/cleanup', {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' }, headers: { 'X-CSRF-Token': this.csrfToken() },
}); });
if (resp.ok) { if (resp.ok) {
this.flash('Cleanup queued.'); this.flash('Cleanup queued.');
@@ -313,11 +356,12 @@ function backupDashboard() {
}, },
async deleteBackup(id, filename) { async deleteBackup(id, filename) {
if (!confirm(`Delete backup "${filename}"? This cannot be undone.`)) return; const ok = await this.askConfirm(`Delete backup "${filename}"? This cannot be undone.`);
if (!ok) return;
try { try {
const resp = await fetch(`/api/admin/backup/${id}`, { const resp = await fetch(`/api/admin/backup/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' }, headers: { 'X-CSRF-Token': this.csrfToken() },
}); });
if (resp.ok) { if (resp.ok) {
this.flash(`Backup ${filename} deleted.`); this.flash(`Backup ${filename} deleted.`);
@@ -337,14 +381,15 @@ function backupDashboard() {
const fileInput = document.getElementById('restoreFile'); const fileInput = document.getElementById('restoreFile');
if (!fileInput.files.length) return; if (!fileInput.files.length) return;
if (!confirm('Are you sure? This will OVERWRITE all current database data with the contents of the backup file.')) return; const ok = await this.askConfirm('Are you sure? This will OVERWRITE all current database data with the contents of the backup file.');
if (!ok) return;
this.restoring = true; this.restoring = true;
try { try {
const formData = new FormData(form); const formData = new FormData(form);
const resp = await fetch('/api/admin/backup/restore', { const resp = await fetch('/api/admin/backup/restore', {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': document.querySelector('[name=csrf_token]')?.value || '' }, headers: { 'X-CSRF-Token': this.csrfToken() },
body: formData, body: formData,
}); });
if (resp.ok) { if (resp.ok) {