Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions frontend/app/routes/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -292,6 +293,20 @@ export default function Settings() {
)}
</div>

{/* Backup */}
<div className="bg-card rounded-2xl border border-border p-5 mb-8">
<h2 className="text-lg font-semibold mb-4 text-foreground">Backup</h2>
<a
href={getBackupUrl()}
className="inline-block bg-blue-600 text-white hover:bg-blue-900 text-sm font-medium py-2 px-4 rounded-lg"
>
Download a backup
</a>
<p className="text-sm text-muted-foreground mt-3">
A zip of your uploads and the database. Take one before updating.
</p>
</div>

{/* Provider Settings */}
<div className="bg-card rounded-2xl border border-border p-5">
<h2 className="text-lg font-semibold mb-4 text-foreground">External Providers</h2>
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/utils/galleryApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
67 changes: 67 additions & 0 deletions tests/test_backup.py
Original file line number Diff line number Diff line change
@@ -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}"