diff --git a/app.py b/app.py index 5d63d49..72c64a1 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,9 @@ import base64 -from flask import Flask, render_template, request, redirect, url_for, flash, send_file, send_from_directory, jsonify, Response +import shutil +import sqlite3 +import tempfile +import zipfile +from flask import Flask, after_this_request, render_template, request, redirect, url_for, flash, send_file, send_from_directory, jsonify, Response import os from werkzeug.utils import secure_filename from pathlib import Path @@ -251,6 +255,45 @@ def api_list_images(): return {'images': files} +@app.route('/api/backup', methods=['GET']) +def api_backup(): + """Download a zip of the uploads and the database.""" + stamp = datetime.now().strftime('%Y%m%d-%H%M%S') + tmp_dir = tempfile.mkdtemp(prefix='frametv-backup-') + archive_path = os.path.join(tmp_dir, f'frametv-backup-{stamp}.zip') + + try: + db_snapshot = os.path.join(tmp_dir, 'frametv.db') + source = sqlite3.connect(frametv_db_path) + try: + destination = sqlite3.connect(db_snapshot) + try: + source.backup(destination) + finally: + destination.close() + finally: + source.close() + + upload_folder = app.config['UPLOAD_FOLDER'] + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.write(db_snapshot, 'instance/frametv.db') + for filename in sorted(os.listdir(upload_folder)): + full = os.path.join(upload_folder, filename) + if os.path.isfile(full): + archive.write(full, f'uploads/{filename}') + + @after_this_request + def cleanup(response): + shutil.rmtree(tmp_dir, ignore_errors=True) + return response + + return send_file(archive_path, as_attachment=True, download_name=os.path.basename(archive_path)) + except Exception as e: + shutil.rmtree(tmp_dir, ignore_errors=True) + _log_exception('Failed to build the backup archive', e) + return _error_response('Failed to build the backup archive', 500) + + @app.route('/api/images/added_this_month', methods=['GET']) def api_images_added_this_month(): now = datetime.now() diff --git a/frontend/app/routes/settings.tsx b/frontend/app/routes/settings.tsx index e8402f5..a155d5a 100644 --- a/frontend/app/routes/settings.tsx +++ b/frontend/app/routes/settings.tsx @@ -5,6 +5,7 @@ import { fetchAlbums } from '~/utils/galleryApi'; import { Input } from '~/components/ui/input'; import { Button } from '~/components/ui/button'; import { getProviders, setProvider, getProvider, deleteProvider } from '~/utils/providerApi'; +import { getBackupUrl } from '~/utils/galleryApi'; import type { ProviderConfig } from '~/utils/providerApi'; @@ -292,6 +293,20 @@ export default function Settings() { )} + {/* Backup */} +
+

Backup

+ + Download a backup + +

+ A zip of your uploads and the database. Take one before updating. +

+
+ {/* Provider Settings */}

External Providers

diff --git a/frontend/app/utils/galleryApi.ts b/frontend/app/utils/galleryApi.ts index d53498c..f71d8a7 100644 --- a/frontend/app/utils/galleryApi.ts +++ b/frontend/app/utils/galleryApi.ts @@ -25,6 +25,10 @@ export async function fetchImages() { return (await res.json()).images; } +export function getBackupUrl() { + return `${API_BASE}/api/backup`; +} + export async function deleteImage(filename: string) { const res = await fetch(`${API_BASE}/api/images/${encodeURIComponent(filename)}`, { method: 'DELETE', diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..987d6c2 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,67 @@ +"""Covers the downloadable backup archive. + +Run with: pytest tests/test_backup.py +""" + +import io +import os +import tempfile +import zipfile + +import pytest +from PIL import Image as PILImage + +import app as backend + + +@pytest.fixture +def client(): + backend.app.config["TESTING"] = True + with backend.app.app_context(): + backend.db.drop_all() + backend.db.create_all() + for name in os.listdir(backend.app.config["UPLOAD_FOLDER"]): + path = os.path.join(backend.app.config["UPLOAD_FOLDER"], name) + if os.path.isfile(path): + os.remove(path) + return backend.app.test_client() + + +def upload(client, name): + buf = io.BytesIO() + PILImage.new("RGB", (60, 40), "red").save(buf, format="PNG") + buf.seek(0) + return client.post( + "/api/upload", + data={"file": (buf, name)}, + content_type="multipart/form-data", + ) + + +def test_the_backup_contains_the_database_and_the_uploads(client): + upload(client, "kept.png") + res = client.get("/api/backup") + assert res.status_code == 200 + + with zipfile.ZipFile(io.BytesIO(res.data)) as archive: + names = archive.namelist() + assert "instance/frametv.db" in names + assert "uploads/kept.png" in names + assert archive.read("instance/frametv.db").startswith(b"SQLite format 3") + + +def test_the_backup_is_readable_and_leaves_nothing_behind(client): + """The archive is built in a temp directory that has to be cleaned up after.""" + upload(client, "kept.png") + before = set(os.listdir(tempfile.gettempdir())) + + res = client.get("/api/backup") + assert res.status_code == 200 + with zipfile.ZipFile(io.BytesIO(res.data)) as archive: + assert archive.testzip() is None, "the archive should not be corrupt" + + leftovers = { + name for name in set(os.listdir(tempfile.gettempdir())) - before + if name.startswith("frametv-backup-") + } + assert leftovers == set(), f"temporary files were left behind: {leftovers}"