From 897403f7cc420ef2f2686bcc6b3e4ef5146a383f Mon Sep 17 00:00:00 2001 From: dgtlmoon Date: Wed, 18 Feb 2026 18:05:32 +0100 Subject: [PATCH] UI - Backup restore (#3899) --- .../blueprint/backups/__init__.py | 31 ++- .../blueprint/backups/restore.py | 208 ++++++++++++++++++ .../backups/templates/backup_create.html | 49 +++++ .../backups/templates/backup_restore.html | 58 +++++ .../blueprint/backups/templates/overview.html | 36 --- .../blueprint/imports/templates/import.html | 11 +- .../settings/templates/settings.html | 2 +- changedetectionio/tests/test_backup.py | 93 +++++++- 8 files changed, 432 insertions(+), 56 deletions(-) create mode 100644 changedetectionio/blueprint/backups/restore.py create mode 100644 changedetectionio/blueprint/backups/templates/backup_create.html create mode 100644 changedetectionio/blueprint/backups/templates/backup_restore.html delete mode 100644 changedetectionio/blueprint/backups/templates/overview.html diff --git a/changedetectionio/blueprint/backups/__init__.py b/changedetectionio/blueprint/backups/__init__.py index 2257b1543..efab21213 100644 --- a/changedetectionio/blueprint/backups/__init__.py +++ b/changedetectionio/blueprint/backups/__init__.py @@ -13,7 +13,7 @@ from loguru import logger BACKUP_FILENAME_FORMAT = "changedetection-backup-{}.zip" -def create_backup(datastore_path, watches: dict): +def create_backup(datastore_path, watches: dict, tags: dict = None): logger.debug("Creating backup...") import zipfile from pathlib import Path @@ -45,6 +45,15 @@ def create_backup(datastore_path, watches: dict): if os.path.isfile(secret_file): zipObj.write(secret_file, arcname="secret.txt") + # Add tag data directories (each tag has its own {uuid}/tag.json) + for uuid, tag in (tags or {}).items(): + for f in Path(tag.data_dir).glob('*'): + zipObj.write(f, + arcname=os.path.join(f.parts[-2], f.parts[-1]), + compress_type=zipfile.ZIP_DEFLATED, + compresslevel=8) + logger.debug(f"Added tag '{tag.get('title')}' ({uuid}) to backup") + # Add any data in the watch data directory. for uuid, w in watches.items(): for f in Path(w.data_dir).glob('*'): @@ -88,7 +97,10 @@ def create_backup(datastore_path, watches: dict): def construct_blueprint(datastore: ChangeDetectionStore): + from .restore import construct_restore_blueprint + backups_blueprint = Blueprint('backups', __name__, template_folder="templates") + backups_blueprint.register_blueprint(construct_restore_blueprint(datastore)) backup_threads = [] @login_optionally_required @@ -96,16 +108,17 @@ def construct_blueprint(datastore: ChangeDetectionStore): def request_backup(): if any(thread.is_alive() for thread in backup_threads): flash(gettext("A backup is already running, check back in a few minutes"), "error") - return redirect(url_for('backups.index')) + return redirect(url_for('backups.create')) if len(find_backups()) > int(os.getenv("MAX_NUMBER_BACKUPS", 100)): flash(gettext("Maximum number of backups reached, please remove some"), "error") - return redirect(url_for('backups.index')) + return redirect(url_for('backups.create')) # With immediate persistence, all data is already saved zip_thread = threading.Thread( target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching")), + kwargs={'tags': datastore.data['settings']['application'].get('tags', {})}, daemon=True, name="BackupCreator" ) @@ -113,7 +126,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): backup_threads.append(zip_thread) flash(gettext("Backup building in background, check back in a few minutes.")) - return redirect(url_for('backups.index')) + return redirect(url_for('backups.create')) def find_backups(): backup_filepath = os.path.join(datastore.datastore_path, BACKUP_FILENAME_FORMAT.format("*")) @@ -155,14 +168,14 @@ def construct_blueprint(datastore: ChangeDetectionStore): return send_from_directory(os.path.abspath(datastore.datastore_path), filename, as_attachment=True) @login_optionally_required - @backups_blueprint.route("", methods=['GET']) - def index(): + @backups_blueprint.route("/", methods=['GET']) + @backups_blueprint.route("/create", methods=['GET']) + def create(): backups = find_backups() - output = render_template("overview.html", + output = render_template("backup_create.html", available_backups=backups, backup_running=any(thread.is_alive() for thread in backup_threads) ) - return output @login_optionally_required @@ -176,6 +189,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash(gettext("Backups were deleted.")) - return redirect(url_for('backups.index')) + return redirect(url_for('backups.create')) return backups_blueprint diff --git a/changedetectionio/blueprint/backups/restore.py b/changedetectionio/blueprint/backups/restore.py new file mode 100644 index 000000000..c283330eb --- /dev/null +++ b/changedetectionio/blueprint/backups/restore.py @@ -0,0 +1,208 @@ +import io +import json +import os +import shutil +import tempfile +import threading +import zipfile + +from flask import Blueprint, render_template, flash, url_for, redirect, request +from flask_babel import gettext, lazy_gettext as _l +from wtforms import Form, BooleanField, SubmitField +from flask_wtf.file import FileField, FileAllowed +from loguru import logger + +from changedetectionio.flask_app import login_optionally_required + + +class RestoreForm(Form): + zip_file = FileField(_l('Backup zip file'), validators=[ + FileAllowed(['zip'], _l('Must be a .zip backup file!')) + ]) + include_groups = BooleanField(_l('Include groups'), default=True) + include_groups_replace_existing = BooleanField(_l('Replace existing groups of the same UUID'), default=True) + include_watches = BooleanField(_l('Include watches'), default=True) + include_watches_replace_existing = BooleanField(_l('Replace existing watches of the same UUID'), default=True) + submit = SubmitField(_l('Restore backup')) + + +def import_from_zip(zip_stream, datastore, include_groups, include_groups_replace, include_watches, include_watches_replace): + """ + Extract and import watches and groups from a backup zip stream. + + Mirrors the store's _load_watches / _load_tags loading pattern: + - UUID dirs with tag.json → Tag.model + tag_obj.commit() + - UUID dirs with watch.json → rehydrate_entity + watch_obj.commit() + + Returns a dict with counts: restored_groups, skipped_groups, restored_watches, skipped_watches. + Raises zipfile.BadZipFile if the stream is not a valid zip. + """ + from changedetectionio.model import Tag + + restored_groups = 0 + skipped_groups = 0 + restored_watches = 0 + skipped_watches = 0 + + current_tags = datastore.data['settings']['application'].get('tags', {}) + current_watches = datastore.data['watching'] + + with tempfile.TemporaryDirectory() as tmpdir: + logger.debug(f"Restore: extracting zip to {tmpdir}") + with zipfile.ZipFile(zip_stream, 'r') as zf: + zf.extractall(tmpdir) + logger.debug("Restore: zip extracted, scanning UUID directories") + + for entry in os.scandir(tmpdir): + if not entry.is_dir(): + continue + + uuid = entry.name + tag_json_path = os.path.join(entry.path, 'tag.json') + watch_json_path = os.path.join(entry.path, 'watch.json') + + # --- Tags (groups) --- + if include_groups and os.path.exists(tag_json_path): + if uuid in current_tags and not include_groups_replace: + logger.debug(f"Restore: skipping existing group {uuid} (replace not requested)") + skipped_groups += 1 + continue + + try: + with open(tag_json_path, 'r', encoding='utf-8') as f: + tag_data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Restore: failed to read tag.json for {uuid}: {e}") + continue + + title = tag_data.get('title', uuid) + logger.debug(f"Restore: importing group '{title}' ({uuid})") + + # Mirror _load_tags: set uuid and force processor + tag_data['uuid'] = uuid + tag_data['processor'] = 'restock_diff' + + # Copy the UUID directory so data_dir exists for commit() + dst_dir = os.path.join(datastore.datastore_path, uuid) + if os.path.exists(dst_dir): + shutil.rmtree(dst_dir) + shutil.copytree(entry.path, dst_dir) + + tag_obj = Tag.model( + datastore_path=datastore.datastore_path, + __datastore=datastore.data, + default=tag_data + ) + current_tags[uuid] = tag_obj + tag_obj.commit() + restored_groups += 1 + logger.success(f"Restore: group '{title}' ({uuid}) restored") + + # --- Watches --- + elif include_watches and os.path.exists(watch_json_path): + if uuid in current_watches and not include_watches_replace: + logger.debug(f"Restore: skipping existing watch {uuid} (replace not requested)") + skipped_watches += 1 + continue + + try: + with open(watch_json_path, 'r', encoding='utf-8') as f: + watch_data = json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Restore: failed to read watch.json for {uuid}: {e}") + continue + + url = watch_data.get('url', uuid) + logger.debug(f"Restore: importing watch '{url}' ({uuid})") + + # Copy UUID directory first so data_dir and history files exist + dst_dir = os.path.join(datastore.datastore_path, uuid) + if os.path.exists(dst_dir): + shutil.rmtree(dst_dir) + shutil.copytree(entry.path, dst_dir) + + # Mirror _load_watches / rehydrate_entity + watch_data['uuid'] = uuid + watch_obj = datastore.rehydrate_entity(uuid, watch_data) + current_watches[uuid] = watch_obj + watch_obj.commit() + restored_watches += 1 + logger.success(f"Restore: watch '{url}' ({uuid}) restored") + + logger.debug(f"Restore: scan complete - groups {restored_groups} restored / {skipped_groups} skipped, " + f"watches {restored_watches} restored / {skipped_watches} skipped") + + # Persist changedetection.json (includes the updated tags dict) + logger.debug("Restore: committing datastore settings") + datastore.commit() + + return { + 'restored_groups': restored_groups, + 'skipped_groups': skipped_groups, + 'restored_watches': restored_watches, + 'skipped_watches': skipped_watches, + } + + + +def construct_restore_blueprint(datastore): + restore_blueprint = Blueprint('restore', __name__, template_folder="templates") + restore_threads = [] + + @login_optionally_required + @restore_blueprint.route("/restore", methods=['GET']) + def restore(): + form = RestoreForm() + return render_template("backup_restore.html", + form=form, + restore_running=any(t.is_alive() for t in restore_threads)) + + @login_optionally_required + @restore_blueprint.route("/restore/start", methods=['POST']) + def backups_restore_start(): + if any(t.is_alive() for t in restore_threads): + flash(gettext("A restore is already running, check back in a few minutes"), "error") + return redirect(url_for('backups.restore.restore')) + + zip_file = request.files.get('zip_file') + if not zip_file or not zip_file.filename: + flash(gettext("No file uploaded"), "error") + return redirect(url_for('backups.restore.restore')) + + if not zip_file.filename.lower().endswith('.zip'): + flash(gettext("File must be a .zip backup file"), "error") + return redirect(url_for('backups.restore.restore')) + + # Read into memory now — the request stream is gone once we return + try: + zip_bytes = io.BytesIO(zip_file.read()) + zipfile.ZipFile(zip_bytes) # quick validity check before spawning + zip_bytes.seek(0) + except zipfile.BadZipFile: + flash(gettext("Invalid or corrupted zip file"), "error") + return redirect(url_for('backups.restore.restore')) + + include_groups = request.form.get('include_groups') == 'y' + include_groups_replace = request.form.get('include_groups_replace_existing') == 'y' + include_watches = request.form.get('include_watches') == 'y' + include_watches_replace = request.form.get('include_watches_replace_existing') == 'y' + + restore_thread = threading.Thread( + target=import_from_zip, + kwargs={ + 'zip_stream': zip_bytes, + 'datastore': datastore, + 'include_groups': include_groups, + 'include_groups_replace': include_groups_replace, + 'include_watches': include_watches, + 'include_watches_replace': include_watches_replace, + }, + daemon=True, + name="BackupRestore" + ) + restore_thread.start() + restore_threads.append(restore_thread) + flash(gettext("Restore started in background, check back in a few minutes.")) + return redirect(url_for('backups.restore.restore')) + + return restore_blueprint diff --git a/changedetectionio/blueprint/backups/templates/backup_create.html b/changedetectionio/blueprint/backups/templates/backup_create.html new file mode 100644 index 000000000..21678fd27 --- /dev/null +++ b/changedetectionio/blueprint/backups/templates/backup_create.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% block content %} + {% from '_helpers.html' import render_simple_field, render_field %} + +
+ +
+
+ {% if backup_running %} +

+  {{ _('A backup is running!') }} +

+ {% endif %} + +

+ {{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }} +

+
+ {% if available_backups %} +
    + {% for backup in available_backups %} +
  • + {{ backup["filename"] }} {{ backup["filesize"] }} {{ _('Mb') }} +
  • + {% endfor %} +
+ {% else %} +

+ {{ _('No backups found.') }} +

+ {% endif %} + + {{ _('Create backup') }} + {% if available_backups %} + {{ _('Remove backups') }} + {% endif %} + +
+ +
+
+{% endblock %} diff --git a/changedetectionio/blueprint/backups/templates/backup_restore.html b/changedetectionio/blueprint/backups/templates/backup_restore.html new file mode 100644 index 000000000..429a23be4 --- /dev/null +++ b/changedetectionio/blueprint/backups/templates/backup_restore.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} +{% block content %} + {% from '_helpers.html' import render_field, render_checkbox_field %} + +
+ +
+
+ {% if restore_running %} +

+  {{ _('A restore is running!') }} +

+ {% endif %} + +

{{ _('Restore a backup. Must be a .zip backup file created on/after v0.53.1 (new database layout).') }}

+

{{ _('Note: This does not override the main application settings, only watches and groups.') }}

+ +
+ + +
+ {{ render_checkbox_field(form.include_groups) }} + {{ _('Include all groups found in backup?') }} +
+
+ {{ render_checkbox_field(form.include_groups_replace_existing) }} + {{ _('Replace any existing groups of the same UUID?') }} +
+ +
+ {{ render_checkbox_field(form.include_watches) }} + {{ _('Include all watches found in backup?') }} +
+
+ {{ render_checkbox_field(form.include_watches_replace_existing) }} + {{ _('Replace any existing watches of the same UUID?') }} +
+ +
+ {{ render_field(form.zip_file) }} +
+ +
+ +
+
+
+
+
+{% endblock %} diff --git a/changedetectionio/blueprint/backups/templates/overview.html b/changedetectionio/blueprint/backups/templates/overview.html deleted file mode 100644 index 07b0af8af..000000000 --- a/changedetectionio/blueprint/backups/templates/overview.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends 'base.html' %} -{% block content %} - {% from '_helpers.html' import render_simple_field, render_field %} -
-
-

{{ _('Backups') }}

- {% if backup_running %} -

-  {{ _('A backup is running!') }} -

- {% endif %} -

- {{ _('Here you can download and request a new backup, when a backup is completed you will see it listed below.') }} -

-
- {% if available_backups %} -
    - {% for backup in available_backups %} -
  • {{ backup["filename"] }} {{ backup["filesize"] }} {{ _('Mb') }}
  • - {% endfor %} -
- {% else %} -

- {{ _('No backups found.') }} -

- {% endif %} - - {{ _('Create backup') }} - {% if available_backups %} - {{ _('Remove backups') }} - {% endif %} -
-
- - -{% endblock %} diff --git a/changedetectionio/blueprint/imports/templates/import.html b/changedetectionio/blueprint/imports/templates/import.html index dc623b9e7..a03504782 100644 --- a/changedetectionio/blueprint/imports/templates/import.html +++ b/changedetectionio/blueprint/imports/templates/import.html @@ -16,6 +16,11 @@
+ +

+ {{ _('Restoring changedetection.io backups is in the') }} {{ _('backups section') }}. +
+

{{ _('Enter one URL per line, and optionally add tags for each URL after a space, delineated by comma (,):') }}
@@ -37,9 +42,6 @@
- - -
{{ _('Copy and Paste your Distill.io watch \'export\' file, this should be a JSON file.') }}
{{ _('This is') }} {{ _('experimental') }}, {{ _('supported fields are') }} name, uri, tags, config:selections, {{ _('the rest (including') }} schedule) {{ _('are ignored.') }} @@ -49,8 +51,6 @@ {{ _('Be sure to set your default fetcher to Chrome if required.') }}

- -