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:
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user