diff --git a/docs/cloud_backup.md b/docs/cloud_backup.md index 2edd26b..9a600e2 100644 --- a/docs/cloud_backup.md +++ b/docs/cloud_backup.md @@ -14,6 +14,14 @@ The Cloud Backup page manages cloud backup settings, schedules, and status. - **Save**: Button to save schedule settings by disabling during the request. - **Error/Success Feedback**: Inline messages for save actions. +## File Filters +- **Exclude Patterns**: Skip files or folders that match these rclone patterns. +- **Include Patterns**: Back up only files or folders that match these rclone patterns. +- Enter one plain rclone pattern per line. Blank lines and lines that start with `#` or `;` are ignored. +- Do not enter rclone filter rule prefixes such as `+`, `-`, or `!`. SimpleSaferServer creates the rclone `--filter-from` file for the backup run. +- Exclude patterns are written first, then include patterns. If at least one include pattern exists, SimpleSaferServer adds a final `- **` rule so the include list works like an allow-list. +- These pattern files are stored under the app config directory and are passed to rclone through `--filter-from`, so the full pattern list is not placed in the rclone process arguments. + ## Cloud Backup Settings - **Backup Mode**: Choose between: - MEGA (Simple) diff --git a/docs/dashboard.md b/docs/dashboard.md index 10df2bb..712e1a2 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -16,7 +16,8 @@ Four cards display real-time status: - **System Resources**: Displays CPU and RAM usage, and live network traffic (up/down rates). ## Task Schedule -- **Table**: Lists all scheduled tasks with columns for Task, Status, Last Run, and Next Run. +- **Table**: Lists all scheduled tasks with columns for Task, Status, Last Run, Next Run, and + Automatic Runs. - **Next Run**: Shows the active next run time or a short schedule state label. Temporary disables show `Disabled until 18:00`, `Disabled until Tomorrow 18:00`, or a later date such as `Disabled until May 16 18:00`. Permanent disables show `Disabled`. Timers disabled outside @@ -24,6 +25,10 @@ Four cards display real-time status: Disabled schedule labels are danger-colored in this field only, so automatic-run suspension stands out without making the entire task row look failed. Schedule issues remain warning-colored because they mean the timer state needs investigation. +- **Automatic Runs**: The `Check Mount`, `Drive Health Check`, and `Cloud Backup` rows have a direct + on/off switch. Turning the switch off permanently disables that task's systemd `.timer`, so the + task no longer starts on its schedule. Turning the switch on enables the timer again. Manual Start + from the task page still starts the `.service` immediately. - **Task Schedule Control**: Right-click a task row to Start, Stop, Disable Schedule, or Enable Schedule when that action applies. The menu stays open across passive schedule refreshes so the operator does not lose the selected row actions while reading the menu. diff --git a/docs/setup.md b/docs/setup.md index 2a59667..a62a64a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -115,6 +115,10 @@ Advanced mode: gives the mount check time to finish before health probes the drive, even with systemd's small randomized delay. - The installer may generate those unit files earlier, but it keeps the timers inactive while `system.setup_complete` is false so persistent timers cannot run with placeholder setup values. +- Setup creates a small self-backup archive on the mounted backup drive after completion. +- A daily self-backup timer then runs one minute before cloud backup, so cloud backup can copy the fresh setup archive too. +- Self-backup archives include SimpleSaferServer-owned setup config, rclone config, msmtp config, and owned Samba include files. They do not include `/etc/fstab` or a full system backup. +- Manual backup and restore commands are documented in [Setup Self-Backup](setup_self_backup.md). ## Later Changes diff --git a/docs/setup_self_backup.md b/docs/setup_self_backup.md new file mode 100644 index 0000000..3acf8b3 --- /dev/null +++ b/docs/setup_self_backup.md @@ -0,0 +1,79 @@ +# Setup Self-Backup + +SimpleSaferServer can back up the files that its setup flow owns. This helps you recover the web app setup after a reinstall without copying the whole server. + +This is not a full system backup. + +## What It Includes + +The archive includes these files when they exist: + +- SimpleSaferServer config from `/etc/SimpleSaferServer` +- the user database, secret key, encrypted secrets, alerts, and disabled timer records +- rclone config from root's rclone config folder +- msmtp config from `/etc/msmtprc` +- SimpleSaferServer-owned Samba include files + +It does not include `/etc/fstab`. That is intentional. Restoring an old mount table can make a server fail to boot or mount the wrong disk. + +It also does not include the full backup drive data, operating system files, package state, or unmanaged Samba config. + +## Automatic Backup + +After setup is complete, SimpleSaferServer creates a self-backup on the configured backup drive. + +It also installs a daily `setup_self_backup.timer`. This timer runs one minute before the normal cloud backup time, so the fresh self-backup archive can be copied to your cloud target by the normal cloud backup. + +Archives are stored here on the mounted backup drive: + +```text +SimpleSaferServer-self-backups/ +``` + +Only the newest 30 archives are kept by the scheduled command. + +## Manual Backup + +Run this as root: + +```bash +sudo /opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py create +``` + +To list existing archives: + +```bash +sudo /opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py list +``` + +To write to a specific mounted drive path: + +```bash +sudo /opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py create --destination /media/backup +``` + +## Restore During Setup + +Use this after reinstalling SimpleSaferServer, before finishing the setup wizard. + +1. Mount the backup drive. +2. Find the archive under `SimpleSaferServer-self-backups/`. +3. Restore it: + +```bash +sudo /opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py restore /media/backup/SimpleSaferServer-self-backups/setup-self-backup-YYYYMMDDTHHMMSSZ.tar.gz +``` + +By default, restore sets `system.setup_complete` to `false`. This lets the setup wizard reinstall services, timers, Samba share setup, and the managed backup-drive setup for the current machine. + +After the restore, open the setup wizard and finish setup. Check the backup drive step carefully. The self-backup does not restore `/etc/fstab`, so the current backup drive still needs to be mounted and registered by setup. + +## Restore On A Running Install + +The restore command is mainly meant for reinstall recovery. If you run it on an already working install, restart SimpleSaferServer afterwards so the web app reloads the restored files. + +Only use `--preserve-setup-complete` when you understand that setup will not be forced to rerun service and timer installation: + +```bash +sudo /opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py restore /path/to/archive.tar.gz --preserve-setup-complete +``` diff --git a/docs/task_detail.md b/docs/task_detail.md index bd870e5..537e777 100644 --- a/docs/task_detail.md +++ b/docs/task_detail.md @@ -12,6 +12,9 @@ The Task Detail page shows information and logs for a specific scheduled task. ## Controls - **Start**: Button to start the task (confirmation required). - **Stop**: Button to stop the task (confirmation required). +- **Automatic Runs**: On `Check Mount`, `Drive Health Check`, and `Cloud Backup`, this switch turns + scheduled runs on or off. Off permanently disables that task's systemd `.timer`; On enables it + again. Manual Start still starts the task immediately. - **Disable Schedule**: Opens a modal for disabling automatic runs for 1 hour, 6 hours, 24 hours, 7 days, or permanently. This disables the systemd `.timer` only; manual Start still starts the `.service`. diff --git a/index.html b/index.html index 538ee2f..e54ed77 100644 --- a/index.html +++ b/index.html @@ -159,6 +159,7 @@

Documentati
  • Fake Mode
  • Railway Deployment Notes
  • Setup Guide
  • +
  • Setup Self-Backup
  • Login & User Management
  • Dashboard
  • Drive Health
  • diff --git a/scripts/backup_cloud.sh b/scripts/backup_cloud.sh index efda971..f69139f 100644 --- a/scripts/backup_cloud.sh +++ b/scripts/backup_cloud.sh @@ -1,7 +1,13 @@ #!/bin/bash CONFIG_FILE="/etc/SimpleSaferServer/config.conf" +CONFIG_DIR="/etc/SimpleSaferServer" +RCLONE_INCLUDE_PATTERNS_FILE="$CONFIG_DIR/rclone_include_patterns.txt" +RCLONE_EXCLUDE_PATTERNS_FILE="$CONFIG_DIR/rclone_exclude_patterns.txt" PYTHON_BIN="/opt/SimpleSaferServer/.venv/bin/python" +FILTER_FILE="" +FILTER_RULE_COUNT=0 +INCLUDE_RULE_COUNT=0 if [ ! -x "$PYTHON_BIN" ]; then echo "Missing SimpleSaferServer Python environment at $PYTHON_BIN" >&2 @@ -18,6 +24,58 @@ get_config_value() { ' "$CONFIG_FILE" | tr -d '"' } +cleanup_filter_file() { + if [ -n "$FILTER_FILE" ] && [ -f "$FILTER_FILE" ]; then + rm -f "$FILTER_FILE" + fi +} + +append_filter_patterns() { + local rule_prefix=$1 + local pattern_file=$2 + local pattern + local trimmed + + [ -f "$pattern_file" ] || return 0 + + while IFS= read -r pattern || [ -n "$pattern" ]; do + trimmed=$pattern + trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + if [ -z "$trimmed" ] || [[ "$trimmed" == \#* ]] || [[ "$trimmed" == \;* ]]; then + continue + fi + printf "%s %s\n" "$rule_prefix" "$trimmed" >>"$FILTER_FILE" + FILTER_RULE_COUNT=$((FILTER_RULE_COUNT + 1)) + if [ "$rule_prefix" = "+" ]; then + INCLUDE_RULE_COUNT=$((INCLUDE_RULE_COUNT + 1)) + fi + done <"$pattern_file" +} + +add_rclone_filter_args() { + FILTER_FILE=$(mktemp) + append_filter_patterns "-" "$RCLONE_EXCLUDE_PATTERNS_FILE" + append_filter_patterns "+" "$RCLONE_INCLUDE_PATTERNS_FILE" + + if [ "$INCLUDE_RULE_COUNT" -gt 0 ]; then + # rclone --filter-from reads rules in order. With include rules present, + # this final rule makes the include list act like an allow-list. + printf "%s\n" "- **" >>"$FILTER_FILE" + FILTER_RULE_COUNT=$((FILTER_RULE_COUNT + 1)) + fi + + if [ "$FILTER_RULE_COUNT" -gt 0 ]; then + extra_args+=(--filter-from "$FILTER_FILE") + echo "Using configured rclone file filters." + else + cleanup_filter_file + FILTER_FILE="" + fi +} + +trap cleanup_filter_file EXIT + MOUNT_POINT=$(get_config_value backup mount_point) FROM_ADDRESS=$(get_config_value backup from_address) EMAIL_ADDRESS=$(get_config_value backup email_address) @@ -61,6 +119,8 @@ else extra_args=() fi +add_rclone_filter_args + echo "Starting cloud backup to $RCLONE_DIR..." echo "Source: $MOUNT_POINT" echo "Destination: $RCLONE_DIR" diff --git a/scripts/setup_self_backup.py b/scripts/setup_self_backup.py new file mode 100644 index 0000000..985cc10 --- /dev/null +++ b/scripts/setup_self_backup.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 + +import argparse +import json +import logging +import sys +from pathlib import Path + + +def _add_app_to_path() -> None: + script_path = Path(__file__).resolve() + candidates = [script_path.parents[1], Path("/opt/SimpleSaferServer")] + for candidate in candidates: + if (candidate / "simple_safer_server").is_dir(): + sys.path.insert(0, str(candidate)) + return + + +try: + from simple_safer_server.services.config_manager import ConfigManager + from simple_safer_server.services.runtime import get_runtime + from simple_safer_server.services.setup_self_backup import ( + DEFAULT_RETENTION_COUNT, + SetupSelfBackupError, + SetupSelfBackupService, + ) +except ImportError: + _add_app_to_path() + from simple_safer_server.services.config_manager import ConfigManager + from simple_safer_server.services.runtime import get_runtime + from simple_safer_server.services.setup_self_backup import ( + DEFAULT_RETENTION_COUNT, + SetupSelfBackupError, + SetupSelfBackupService, + ) + + +def _service() -> SetupSelfBackupService: + runtime = get_runtime() + return SetupSelfBackupService(runtime, ConfigManager(runtime=runtime)) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Create, list, or restore SimpleSaferServer setup self-backups." + ) + subparsers = parser.add_subparsers(dest="command") + + create = subparsers.add_parser("create", help="Create a self-backup archive.") + create.add_argument("--destination", help="Mounted backup drive path to write under.") + create.add_argument( + "--keep", + type=int, + default=DEFAULT_RETENTION_COUNT, + help=f"Number of newest archives to keep. Default: {DEFAULT_RETENTION_COUNT}.", + ) + + list_parser = subparsers.add_parser("list", help="List self-backup archives.") + list_parser.add_argument("--destination", help="Mounted backup drive path to inspect.") + list_parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.") + + restore = subparsers.add_parser("restore", help="Restore a self-backup archive.") + restore.add_argument("archive", help="Archive path to restore.") + restore.add_argument( + "--preserve-setup-complete", + action="store_true", + help="Keep system.setup_complete exactly as stored in the archive.", + ) + return parser.parse_args() + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + args = parse_args() + command = args.command or "create" + service = _service() + + if command == "create": + result = service.create_backup( + destination=args.destination, + retention_count=args.keep, + ) + print(f"Created setup self-backup: {result['archive']}") + print(f"Files included: {result['file_count']}") + return 0 + + if command == "list": + backups = service.list_backups(destination=args.destination) + if args.json: + print(json.dumps(backups, indent=2)) + return 0 + if not backups: + print("No setup self-backups found.") + return 0 + for backup in backups: + print(f"{backup['name']} {backup['modified_at']} {backup['size']} bytes") + return 0 + + if command == "restore": + result = service.restore_backup( + args.archive, + force_setup_incomplete=not args.preserve_setup_complete, + ) + print(f"Restored setup self-backup: {result['archive']}") + print(f"Files restored: {len(result['restored'])}") + if not args.preserve_setup_complete: + print("system.setup_complete was set to false so setup can reinstall services.") + return 0 + + raise SetupSelfBackupError(f"Unknown command: {command}") + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SetupSelfBackupError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/simple_safer_server/adapters/rclone.py b/simple_safer_server/adapters/rclone.py index ee15bbb..4411e97 100644 --- a/simple_safer_server/adapters/rclone.py +++ b/simple_safer_server/adapters/rclone.py @@ -14,12 +14,14 @@ def sync( *, config_path: str | None = None, bandwidth_limit: str = "", + filter_from: str | None = None, ): command = self.build_sync_command( source, destination, config_path=config_path, bandwidth_limit=bandwidth_limit, + filter_from=filter_from, ) return self._command_runner.popen( command, @@ -36,10 +38,13 @@ def build_sync_command( *, config_path: str | None = None, bandwidth_limit: str = "", + filter_from: str | None = None, ) -> list[str]: command = ["rclone", "sync", source, destination, "--create-empty-src-dirs", "-v"] if config_path: command.extend(["--config", config_path]) if bandwidth_limit: command.extend(["--bwlimit", bandwidth_limit]) + if filter_from: + command.extend(["--filter-from", filter_from]) return command diff --git a/simple_safer_server/routes/setup_wizard.py b/simple_safer_server/routes/setup_wizard.py index 5c8b589..efb519f 100644 --- a/simple_safer_server/routes/setup_wizard.py +++ b/simple_safer_server/routes/setup_wizard.py @@ -35,6 +35,10 @@ ServerIdentityError, ServerIdentityService, ) +from simple_safer_server.services.setup_self_backup import ( + SetupSelfBackupError, + SetupSelfBackupService, +) from simple_safer_server.services.smb_manager import SMBManager from simple_safer_server.services.system_utils import SystemUtils from simple_safer_server.services.user_manager import UserManager @@ -86,6 +90,11 @@ def _server_identity_service(): return server_identity_service +def _setup_self_backup_service(): + """Return the small backup helper used before and during setup recovery.""" + return SetupSelfBackupService(runtime, config_manager) + + def _valid_tcp_port(value): text = str(value or '').strip() if not text.isdigit(): @@ -854,6 +863,14 @@ def complete_setup(): config_manager.mark_setup_complete() config_manager.load_config() # Ensure in-memory config is up to date + try: + _setup_self_backup_service().create_backup() + except SetupSelfBackupError as e: + # A self-backup is useful, but first-run setup should not fail if + # the drive was unplugged after the mount step. The scheduled timer + # will retry after the backup drive is mounted again. + logger.warning("Setup self-backup was skipped after completion: %s", e) + logger.info( "Setup completed successfully, systemd tasks installed, and SMB share configured." ) @@ -902,6 +919,55 @@ def setup_system_info(): return _operation_problem('Could not save system information') +@setup.route('/api/setup/self-backups', methods=['GET']) +@setup_api_access_required +def list_setup_self_backups(): + """List setup self-backup archives from the configured backup drive.""" + try: + return json_data({"backups": _setup_self_backup_service().list_backups()}) + except ApiProblem: + raise + except Exception as e: + logger.error("Error listing setup self-backups: %s", e) + return _operation_problem("Could not list setup self-backups") + + +@setup.route('/api/setup/self-backups', methods=['POST']) +@setup_api_access_required +def create_setup_self_backup(): + """Create a setup self-backup archive on the configured backup drive.""" + try: + return json_data(_setup_self_backup_service().create_backup()) + except SetupSelfBackupError as e: + return _validation_problem(str(e)) + except ApiProblem: + raise + except Exception as e: + logger.error("Error creating setup self-backup: %s", e) + return _operation_problem("Could not create setup self-backup") + + +@setup.route('/api/setup/self-backups/restore', methods=['POST']) +@setup_api_access_required +def restore_setup_self_backup(): + """Restore a setup self-backup archive before setup completion.""" + try: + data = json_request_data() + archive = data.get("archive") + service = _setup_self_backup_service() + archive_path = service.resolve_backup_name(archive) + result = service.restore_backup(archive_path, force_setup_incomplete=True) + config_manager.load_config() + return json_data(result) + except SetupSelfBackupError as e: + return _validation_problem(str(e)) + except ApiProblem: + raise + except Exception as e: + logger.error("Error restoring setup self-backup: %s", e) + return _operation_problem("Could not restore setup self-backup") + + @setup.route('/api/setup/mega/connect', methods=['POST']) @setup_api_access_required def mega_connect(): diff --git a/simple_safer_server/routes/tasks.py b/simple_safer_server/routes/tasks.py index 0ea3b65..facb1e8 100644 --- a/simple_safer_server/routes/tasks.py +++ b/simple_safer_server/routes/tasks.py @@ -30,6 +30,14 @@ def _bad_disable_schedule_request(message: str): abort(400) +def _task_not_found_response(): + if request.accept_mimetypes.best == "application/json": + return json_problem( + NotFoundProblem("Task not found.", title="Task not found", slug="task-not-found") + ) + abort(404) + + @tasks.route("/dashboard") @admin_required def dashboard(): @@ -122,11 +130,7 @@ def api_task_status(task_name): def start_task(task_name): task = _get_services().task_service.get_task(task_name) if not task: - if request.accept_mimetypes.best == "application/json": - return json_problem( - NotFoundProblem("Task not found.", title="Task not found", slug="task-not-found") - ) - abort(404) + return _task_not_found_response() try: task.start() if request.accept_mimetypes.best == "application/json": @@ -148,11 +152,7 @@ def start_task(task_name): def stop_task(task_name): task = _get_services().task_service.get_task(task_name) if not task: - if request.accept_mimetypes.best == "application/json": - return json_problem( - NotFoundProblem("Task not found.", title="Task not found", slug="task-not-found") - ) - abort(404) + return _task_not_found_response() try: task.stop() if request.accept_mimetypes.best == "application/json": @@ -174,11 +174,7 @@ def stop_task(task_name): def disable_schedule(task_name): task = _get_services().task_service.get_task(task_name) if not task: - if request.accept_mimetypes.best == "application/json": - return json_problem( - NotFoundProblem("Task not found.", title="Task not found", slug="task-not-found") - ) - abort(404) + return _task_not_found_response() data = request.get_json(silent=True) or request.form mode = (data.get("mode") or "").strip() hours = data.get("hours") @@ -222,11 +218,7 @@ def disable_schedule(task_name): def enable_schedule(task_name): task = _get_services().task_service.get_task(task_name) if not task: - if request.accept_mimetypes.best == "application/json": - return json_problem( - NotFoundProblem("Task not found.", title="Task not found", slug="task-not-found") - ) - abort(404) + return _task_not_found_response() try: task.enable_schedule() if request.accept_mimetypes.best == "application/json": @@ -245,6 +237,46 @@ def enable_schedule(task_name): abort(500) +@tasks.route("/task//schedule-enabled", methods=["POST"]) +@api_admin_required +def set_schedule_enabled(task_name): + task_service = _get_services().task_service + task = task_service.get_task(task_name) + if not task: + return _task_not_found_response() + if not task.schedule_toggle_supported: + return json_problem( + ValidationProblem( + "Automatic-run toggle is not available for this task.", + slug="task-schedule-toggle-unavailable", + ) + ) + + data = request.get_json(silent=True) or request.form + enabled = data.get("enabled") + if not isinstance(enabled, bool): + return json_problem( + ValidationProblem( + "enabled must be true or false.", + slug="task-schedule-toggle-validation-error", + ) + ) + + try: + task_service.set_schedule_enabled(task, enabled) + summary = task_service.task_summary(task) + message = f"Automatic runs {'enabled' if enabled else 'disabled'} for {task_name}." + return json_data({"task": summary}, message=message) + except Exception: + current_app.logger.exception("Failed to update automatic runs for %s", task_name) + return json_problem( + OperationProblem( + "Could not update automatic runs. Check systemd status.", + slug="task-operation-failed", + ) + ) + + @tasks.route("/api/tasks/schedule") @api_admin_required def api_tasks_schedule(): diff --git a/simple_safer_server/services/cloud_backup_service.py b/simple_safer_server/services/cloud_backup_service.py index 4397bf3..b1a5910 100644 --- a/simple_safer_server/services/cloud_backup_service.py +++ b/simple_safer_server/services/cloud_backup_service.py @@ -6,6 +6,10 @@ from typing import Any from simple_safer_server.adapters.command_runner import PIPE, CommandRunner +from simple_safer_server.services.rclone_filters import ( + read_rclone_pattern_texts, + write_rclone_pattern_texts, +) from simple_safer_server.services.schedule_time import ( ScheduleTimeError, normalize_ui_schedule_time, @@ -81,6 +85,7 @@ def get_config(self) -> dict[str, Any]: response["rclone_config"] = config_file.read() else: response["rclone_config"] = "" + response.update(read_rclone_pattern_texts(self._runtime)) return response def save_config(self, data: dict[str, Any]) -> dict[str, Any]: @@ -89,6 +94,12 @@ def save_config(self, data: dict[str, Any]) -> dict[str, Any]: self._save_mega_config(data) elif mode == "advanced": self._save_advanced_config(data) + if "rclone_include_patterns" in data or "rclone_exclude_patterns" in data: + write_rclone_pattern_texts( + self._runtime, + include_patterns=data.get("rclone_include_patterns", ""), + exclude_patterns=data.get("rclone_exclude_patterns", ""), + ) backup_time = data.get("backup_cloud_time") bandwidth_limit = data.get("bandwidth_limit") diff --git a/simple_safer_server/services/rclone_filters.py b/simple_safer_server/services/rclone_filters.py new file mode 100644 index 0000000..d7cd03b --- /dev/null +++ b/simple_safer_server/services/rclone_filters.py @@ -0,0 +1,113 @@ +import os +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Any + +from simple_safer_server.services.file_persistence import atomic_write_text +from simple_safer_server.web.problems import ValidationProblem + +RCLONE_INCLUDE_PATTERNS_FILENAME = "rclone_include_patterns.txt" +RCLONE_EXCLUDE_PATTERNS_FILENAME = "rclone_exclude_patterns.txt" + + +def rclone_pattern_paths(runtime: Any) -> tuple[Path, Path]: + """Return the app-owned include and exclude pattern files.""" + return ( + runtime.config_dir / RCLONE_INCLUDE_PATTERNS_FILENAME, + runtime.config_dir / RCLONE_EXCLUDE_PATTERNS_FILENAME, + ) + + +def normalize_rclone_pattern_text(value: Any) -> str: + """Return plain rclone patterns, one per line, ready for app-owned files.""" + if value is None: + return "" + + patterns: list[str] = [] + for line in str(value).splitlines(): + pattern = line.strip() + if not pattern or pattern.startswith(("#", ";")): + continue + if pattern[0] in {"+", "-", "!"}: + raise ValidationProblem( + "Use plain rclone patterns only. Do not start lines with +, -, or !." + ) + patterns.append(pattern) + if not patterns: + return "" + return "\n".join(patterns) + "\n" + + +def read_rclone_pattern_texts(runtime: Any) -> dict[str, str]: + include_path, exclude_path = rclone_pattern_paths(runtime) + return { + "rclone_include_patterns": _read_text_file(include_path), + "rclone_exclude_patterns": _read_text_file(exclude_path), + } + + +def write_rclone_pattern_texts( + runtime: Any, + *, + include_patterns: Any, + exclude_patterns: Any, +) -> None: + include_path, exclude_path = rclone_pattern_paths(runtime) + runtime.config_dir.mkdir(parents=True, exist_ok=True) + runtime.config_dir.chmod(0o700) + atomic_write_text( + include_path, + normalize_rclone_pattern_text(include_patterns), + mode=0o600, + ) + atomic_write_text( + exclude_path, + normalize_rclone_pattern_text(exclude_patterns), + mode=0o600, + ) + + +def build_rclone_filter_text(*, include_patterns: Any, exclude_patterns: Any) -> str: + exclude_text = normalize_rclone_pattern_text(exclude_patterns) + include_text = normalize_rclone_pattern_text(include_patterns) + rules = [f"- {pattern}" for pattern in exclude_text.splitlines()] + include_rules = [f"+ {pattern}" for pattern in include_text.splitlines()] + rules.extend(include_rules) + if include_rules: + # rclone --filter rules do not add the implicit final exclude that + # --include adds, so add it when the admin configured an allow-list. + rules.append("- **") + if not rules: + return "" + return "\n".join(rules) + "\n" + + +def write_temp_rclone_filter_file(runtime: Any) -> str | None: + pattern_texts = read_rclone_pattern_texts(runtime) + filter_text = build_rclone_filter_text( + include_patterns=pattern_texts["rclone_include_patterns"], + exclude_patterns=pattern_texts["rclone_exclude_patterns"], + ) + if not filter_text: + return None + + directory = getattr(runtime, "volatile_dir", None) + if directory is not None: + directory.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile( + delete=False, + mode="w", + prefix="rclone-filter-", + suffix=".txt", + dir=directory, + ) as filter_file: + filter_file.write(filter_text) + temp_path = filter_file.name + os.chmod(temp_path, 0o600) + return temp_path + + +def _read_text_file(path: Path) -> str: + if not path.exists(): + return "" + return path.read_text() diff --git a/simple_safer_server/services/setup_self_backup.py b/simple_safer_server/services/setup_self_backup.py new file mode 100644 index 0000000..09b93f2 --- /dev/null +++ b/simple_safer_server/services/setup_self_backup.py @@ -0,0 +1,308 @@ +import configparser +import io +import os +import tarfile +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +BACKUP_ROOT_NAME = "SimpleSaferServer-self-backups" +MANIFEST_NAME = "manifest.json" +ARCHIVE_PREFIX = "setup-self-backup" +DEFAULT_RETENTION_COUNT = 30 + + +class SetupSelfBackupError(Exception): + """Raised when a setup self-backup cannot be created or restored safely.""" + + +@dataclass(frozen=True) +class BackupItem: + name: str + path: Path + mode: int + + @property + def archive_name(self) -> str: + return f"files/{self.name}" + + +def _atomic_copy_bytes(source: Any, target: Path, *, mode: int) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + temp_path = None + try: + with tempfile.NamedTemporaryFile( + "wb", + dir=str(target.parent), + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as temp_file: + temp_path = Path(temp_file.name) + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + temp_file.write(chunk) + temp_file.flush() + os.fsync(temp_file.fileno()) + temp_path.chmod(mode) + os.replace(temp_path, target) + temp_path = None + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def _force_setup_incomplete(config_text: str) -> str: + parser = configparser.ConfigParser() + parser.read_string(config_text) + if not parser.has_section("system"): + parser.add_section("system") + parser.set("system", "setup_complete", "false") + stream = io.StringIO() + parser.write(stream) + return stream.getvalue() + + +class SetupSelfBackupService: + """Back up only SimpleSaferServer-owned setup files, not the whole host.""" + + def __init__(self, runtime, config_manager) -> None: + self.runtime = runtime + self.config_manager = config_manager + + def _configured_mount_point(self) -> Path: + mount_point = self.config_manager.get_value( + "backup", + "mount_point", + self.runtime.default_mount_point, + ) + return Path(str(mount_point or self.runtime.default_mount_point)) + + def backup_root(self, destination: Path | str | None = None) -> Path: + base = Path(destination) if destination is not None else self._configured_mount_point() + return base / BACKUP_ROOT_NAME + + def _backup_items(self) -> list[BackupItem]: + config_dir = self.runtime.config_dir + samba_dir = self.runtime.samba_dir + candidates = [ + BackupItem("config/config.conf", config_dir / "config.conf", 0o600), + BackupItem("config/users.json", config_dir / "users.json", 0o600), + BackupItem("config/.key", config_dir / ".key", 0o600), + BackupItem("config/.secrets", config_dir / ".secrets", 0o600), + BackupItem("config/.flask-secret-key", config_dir / ".flask-secret-key", 0o600), + BackupItem("config/alerts.json", config_dir / "alerts.json", 0o600), + BackupItem( + "config/disabled_timers.json", self.runtime.data_dir / "disabled_timers.json", 0o600 + ), + BackupItem("rclone/rclone.conf", self.runtime.rclone_config_dir / "rclone.conf", 0o600), + BackupItem("msmtp/msmtprc", self.runtime.msmtp_config_path, 0o600), + BackupItem( + "samba/simple_safer_server_globals.conf", + samba_dir / "simple_safer_server_globals.conf", + 0o644, + ), + BackupItem( + "samba/simple_safer_server_shares.conf", + samba_dir / "simple_safer_server_shares.conf", + 0o644, + ), + ] + return [item for item in candidates if item.path.is_file()] + + def _ensure_destination_ready(self, backup_root: Path) -> None: + mount_point = backup_root.parent + if not self.runtime.is_fake and not mount_point.is_mount(): + raise SetupSelfBackupError( + f"{mount_point} is not a mounted backup drive. Refusing to write a self-backup there." + ) + backup_root.mkdir(parents=True, exist_ok=True) + + def create_backup( + self, + *, + destination: Path | str | None = None, + retention_count: int = DEFAULT_RETENTION_COUNT, + ) -> dict[str, Any]: + backup_root = self.backup_root(destination) + self._ensure_destination_ready(backup_root) + items = self._backup_items() + if not items: + raise SetupSelfBackupError("No setup-owned files were found to back up.") + + created_at = datetime.now(UTC).replace(microsecond=0).isoformat() + timestamp = created_at.replace("-", "").replace(":", "").replace("+00:00", "Z") + archive_path = backup_root / f"{ARCHIVE_PREFIX}-{timestamp}.tar.gz" + manifest = { + "version": 1, + "created_at": created_at, + "note": "This archive contains SimpleSaferServer-owned setup config only. It does not contain /etc/fstab or a whole-system backup.", + "files": [ + { + "name": item.name, + "source_path": str(item.path), + "mode": oct(item.mode), + } + for item in items + ], + } + + with tarfile.open(archive_path, "w:gz") as archive: + manifest_bytes = atomic_json_bytes(manifest) + manifest_info = tarfile.TarInfo(MANIFEST_NAME) + manifest_info.size = len(manifest_bytes) + manifest_info.mtime = int(datetime.now(UTC).timestamp()) + manifest_info.mode = 0o600 + archive.addfile(manifest_info, io.BytesIO(manifest_bytes)) + for item in items: + archive.add(item.path, arcname=item.archive_name, recursive=False) + + archive_path.chmod(0o600) + self.prune_old_backups(retention_count=retention_count, backup_root=backup_root) + return { + "archive": str(archive_path), + "created_at": created_at, + "file_count": len(items), + } + + def list_backups(self, *, destination: Path | str | None = None) -> list[dict[str, Any]]: + backup_root = self.backup_root(destination) + if not backup_root.exists(): + return [] + backups = [] + for archive_path in backup_root.glob(f"{ARCHIVE_PREFIX}-*.tar.gz"): + backups.append( + { + "archive": str(archive_path), + "name": archive_path.name, + "size": archive_path.stat().st_size, + "modified_at": datetime.fromtimestamp( + archive_path.stat().st_mtime, + UTC, + ) + .replace(microsecond=0) + .isoformat(), + } + ) + return sorted(backups, key=lambda item: item["modified_at"], reverse=True) + + def prune_old_backups( + self, + *, + retention_count: int = DEFAULT_RETENTION_COUNT, + backup_root: Path | None = None, + ) -> None: + if retention_count < 1: + return + root = backup_root or self.backup_root() + backups = sorted( + root.glob(f"{ARCHIVE_PREFIX}-*.tar.gz"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + for old_archive in backups[retention_count:]: + old_archive.unlink(missing_ok=True) + + def resolve_backup_name(self, archive_name: str) -> Path: + name = Path(str(archive_name or "")).name + if not name: + raise SetupSelfBackupError("Backup archive name is required.") + backup_root = self.backup_root().resolve() + archive_path = (backup_root / name).resolve() + if archive_path.parent != backup_root: + raise SetupSelfBackupError("Backup archive must be inside the self-backup folder.") + if not archive_path.is_file(): + raise SetupSelfBackupError("Backup archive was not found.") + return archive_path + + def restore_backup( + self, + archive_path: Path | str, + *, + force_setup_incomplete: bool = True, + ) -> dict[str, Any]: + archive_path = Path(archive_path) + if not archive_path.is_file(): + raise SetupSelfBackupError("Backup archive was not found.") + + by_name = {item.name: item for item in self._backup_items_for_restore()} + restored = [] + with tarfile.open(archive_path, "r:gz") as archive: + manifest_member = archive.getmember(MANIFEST_NAME) + manifest_file = archive.extractfile(manifest_member) + if manifest_file is None: + raise SetupSelfBackupError("Backup archive is missing its manifest.") + manifest = atomic_json_loads(manifest_file.read()) + if manifest.get("version") != 1: + raise SetupSelfBackupError("Backup archive version is not supported.") + + for file_info in manifest.get("files", []): + name = file_info.get("name") + item = by_name.get(name) + if item is None: + continue + member_name = f"files/{name}" + member = archive.getmember(member_name) + source = archive.extractfile(member) + if source is None: + raise SetupSelfBackupError(f"Backup archive is missing {name}.") + if name == "config/config.conf" and force_setup_incomplete: + config_text = source.read().decode("utf-8") + config_text = _force_setup_incomplete(config_text) + source = io.BytesIO(config_text.encode("utf-8")) + _atomic_copy_bytes(source, item.path, mode=item.mode) + restored.append(name) + + self.runtime.config_dir.mkdir(parents=True, exist_ok=True) + self.runtime.config_dir.chmod(0o700) + return {"archive": str(archive_path), "restored": restored} + + def _backup_items_for_restore(self) -> list[BackupItem]: + config_dir = self.runtime.config_dir + samba_dir = self.runtime.samba_dir + return [ + BackupItem("config/config.conf", config_dir / "config.conf", 0o600), + BackupItem("config/users.json", config_dir / "users.json", 0o600), + BackupItem("config/.key", config_dir / ".key", 0o600), + BackupItem("config/.secrets", config_dir / ".secrets", 0o600), + BackupItem("config/.flask-secret-key", config_dir / ".flask-secret-key", 0o600), + BackupItem("config/alerts.json", config_dir / "alerts.json", 0o600), + BackupItem( + "config/disabled_timers.json", self.runtime.data_dir / "disabled_timers.json", 0o600 + ), + BackupItem("rclone/rclone.conf", self.runtime.rclone_config_dir / "rclone.conf", 0o600), + BackupItem("msmtp/msmtprc", self.runtime.msmtp_config_path, 0o600), + BackupItem( + "samba/simple_safer_server_globals.conf", + samba_dir / "simple_safer_server_globals.conf", + 0o644, + ), + BackupItem( + "samba/simple_safer_server_shares.conf", + samba_dir / "simple_safer_server_shares.conf", + 0o644, + ), + ] + + +def atomic_json_bytes(payload: dict[str, Any]) -> bytes: + return atomic_json_dumps(payload).encode("utf-8") + + +def atomic_json_dumps(payload: dict[str, Any]) -> str: + import json + + return json.dumps(payload, indent=2, sort_keys=True) + + +def atomic_json_loads(payload: bytes) -> dict[str, Any]: + import json + + data = json.loads(payload.decode("utf-8")) + if not isinstance(data, dict): + raise SetupSelfBackupError("Backup manifest is invalid.") + return data diff --git a/simple_safer_server/services/system_utils.py b/simple_safer_server/services/system_utils.py index 4416f2e..bfd87c3 100644 --- a/simple_safer_server/services/system_utils.py +++ b/simple_safer_server/services/system_utils.py @@ -140,6 +140,7 @@ def install_systemd_scripts(self, config): 'check_health.sh', 'check_health.py', 'backup_cloud.sh', + 'setup_self_backup.py', 'app_update.sh', 'app_update.py', 'log_alert.py', @@ -248,6 +249,14 @@ def install_systemd_services_and_timers(self, config, activate_timers=True): minutes_before=2, ) check_health_time = f"{check_health_hour:02d}:{check_health_minute:02d}:00" + setup_self_backup_hour, setup_self_backup_minute = _time_before( + backup_hour, + backup_minute, + minutes_before=1, + ) + setup_self_backup_time = ( + f"{setup_self_backup_hour:02d}:{setup_self_backup_minute:02d}:00" + ) check_mount_hour, check_mount_minute = _time_before( backup_hour, @@ -303,6 +312,22 @@ def install_systemd_services_and_timers(self, config, activate_timers=True): StandardOutput=journal StandardError=journal +[Install] +WantedBy=multi-user.target +""", + 'setup_self_backup.service': """[Unit] +Description=Back up SimpleSaferServer setup configuration to the backup drive +After=check_health.service +Wants=check_health.service + +[Service] +Type=oneshot +# Bypass the script shebang here so systemd never falls back to distro Python. +ExecStart=/opt/SimpleSaferServer/.venv/bin/python /opt/SimpleSaferServer/scripts/setup_self_backup.py create +User=root +StandardOutput=journal +StandardError=journal + [Install] WantedBy=multi-user.target """, @@ -336,6 +361,17 @@ def install_systemd_services_and_timers(self, config, activate_timers=True): Persistent=true RandomizedDelaySec=60 +[Install] +WantedBy=timers.target +""", + 'setup_self_backup.timer': f"""[Unit] +Description=Back up setup configuration before cloud backup + +[Timer] +OnCalendar=*-*-* {setup_self_backup_time} +Persistent=true +RandomizedDelaySec=30 + [Install] WantedBy=timers.target """, @@ -438,6 +474,7 @@ def install_systemd_services_and_timers(self, config, activate_timers=True): 'check_mount', 'check_health', 'backup_cloud', + 'setup_self_backup', 'ddns_update', 'app_update', ]: @@ -461,6 +498,7 @@ def install_systemd_services_and_timers(self, config, activate_timers=True): 'check_mount', 'check_health', 'backup_cloud', + 'setup_self_backup', 'ddns_update', 'app_update', ]: diff --git a/simple_safer_server/services/task_service.py b/simple_safer_server/services/task_service.py index c56ab60..2f1593e 100644 --- a/simple_safer_server/services/task_service.py +++ b/simple_safer_server/services/task_service.py @@ -23,6 +23,7 @@ hdsentinel_snapshot_has_health, run_scheduled_drive_health_check, ) +from simple_safer_server.services.rclone_filters import write_temp_rclone_filter_file class Status: @@ -36,6 +37,7 @@ class Status: TERMINAL_FAKE_STATUSES = {Status.SUCCESS, Status.FAILURE, Status.ERROR, Status.STOPPED} +SCHEDULE_TOGGLE_TASK_NAMES = frozenset({"Check Mount", "Drive Health Check", "Cloud Backup"}) # Keep one app-wide task-log window so routes, auto-refresh, and service defaults # do not quietly drift apart after app-update output grows or shrinks. @@ -101,6 +103,10 @@ def disable_schedule(self, mode: str, hours: int | None = None) -> None: def enable_schedule(self) -> None: self._service.enable_schedule(self) + @property + def schedule_toggle_supported(self) -> bool: + return self.name in SCHEDULE_TOGGLE_TASK_NAMES + @property def next_run(self) -> str: return self._service.get_next_run(self) @@ -171,6 +177,7 @@ def task_summary(self, task: Task) -> dict[str, Any]: "status": task.status, "last_run_duration": task.last_run_duration, "schedule": schedule, + "schedule_toggle_supported": task.schedule_toggle_supported, } except Exception as exc: if self.logger: @@ -181,6 +188,7 @@ def task_summary(self, task: Task) -> dict[str, Any]: "last_run": "Error", "status": "Error", "last_run_duration": "Error", + "schedule_toggle_supported": task.schedule_toggle_supported, "schedule": { "state": "issue", "label": "Schedule issue", @@ -273,6 +281,16 @@ def disable_schedule( def enable_schedule(self, task: Task) -> None: self.disabled_timer_service.enable(task.timer_name) + def set_schedule_enabled(self, task: Task, enabled: bool) -> None: + if not task.schedule_toggle_supported: + raise ValueError(f"Automatic-run toggle is not available for {task.name}.") + # The dashboard switch is a plain on/off control, so off maps to a + # permanent timer disable. The existing modal still covers timed pauses. + if enabled: + self.enable_schedule(task) + else: + self.disable_schedule(task, "permanent") + def schedule_state(self, task: Task) -> dict[str, Any]: raw_next_run = self.get_next_run(task) record = self.disabled_timer_service.get_record(task.timer_name) @@ -508,22 +526,28 @@ def _run_fake_cloud_backup(self, cancel_event: threading.Event) -> None: f"Starting backup from {source} to {destination}", ) bandwidth_limit = self.config_manager.get_value("backup", "bandwidth_limit", "").strip() - proc = self.rclone_adapter.sync( - source, - destination, - config_path=str(rclone_config_path) if rclone_config_path.exists() else None, - bandwidth_limit=bandwidth_limit, - ) - stdout_output, stderr_output = self._collect_process_output( - proc, cancel_event, "fake-cloud-backup" - ) - output = f"{stdout_output}{stderr_output}" - if output.strip(): - fake_state.append_task_log("Cloud Backup", output.strip()) - if cancel_event.is_set(): - raise RuntimeError("Cloud backup was cancelled.") - if proc.returncode != 0: - raise RuntimeError(output.strip() or "Cloud backup failed.") + filter_from = write_temp_rclone_filter_file(self.runtime) + try: + proc = self.rclone_adapter.sync( + source, + destination, + config_path=str(rclone_config_path) if rclone_config_path.exists() else None, + bandwidth_limit=bandwidth_limit, + filter_from=filter_from, + ) + stdout_output, stderr_output = self._collect_process_output( + proc, cancel_event, "fake-cloud-backup" + ) + output = f"{stdout_output}{stderr_output}" + if output.strip(): + fake_state.append_task_log("Cloud Backup", output.strip()) + if cancel_event.is_set(): + raise RuntimeError("Cloud backup was cancelled.") + if proc.returncode != 0: + raise RuntimeError(output.strip() or "Cloud backup failed.") + finally: + if filter_from: + os.remove(filter_from) def _start_fake_task(self, task_name: str) -> None: fake_state = self._require_fake_state() diff --git a/static/css/styles.css b/static/css/styles.css index 47f2288..4f13d56 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -545,6 +545,112 @@ body.fake-mode-active .toast-stack { .task-schedule-danger { color: var(--danger-text); } .task-schedule-warning { color: var(--warning-text); } +/* ── Toggle Switches ───────────────────────────────────────── */ +.toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + width: 44px; + height: 24px; + flex: 0 0 44px; +} + +.toggle-switch input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + margin: 0; + z-index: 1; +} + +.toggle-slider { + position: absolute; + inset: 0; + border-radius: var(--radius-full); + background: var(--bg-overlay); + border: 1px solid var(--border-default); + transition: background var(--dur-fast) var(--ease-default), + border-color var(--dur-fast) var(--ease-default); +} + +.toggle-slider::before { + content: ""; + position: absolute; + width: 18px; + height: 18px; + top: 2px; + left: 2px; + border-radius: 50%; + background: var(--text-secondary); + box-shadow: var(--shadow-sm); + transition: transform var(--dur-fast) var(--ease-default), + background var(--dur-fast) var(--ease-default); +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--success-subtle); + border-color: var(--success); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); + background: var(--success-text); +} + +.toggle-switch input:focus-visible + .toggle-slider { + box-shadow: 0 0 0 3px var(--accent-subtle); +} + +.toggle-switch input:disabled { + cursor: not-allowed; +} + +.toggle-switch input:disabled + .toggle-slider { + opacity: 0.55; +} + +.toggle-switch-sm { + width: 38px; + height: 22px; + flex-basis: 38px; +} + +.toggle-switch-sm .toggle-slider::before { + width: 16px; + height: 16px; +} + +.toggle-switch-sm input:checked + .toggle-slider::before { + transform: translateX(16px); +} + +.task-schedule-toggle-control { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + white-space: nowrap; + cursor: pointer; +} + +.task-schedule-toggle-control-compact { + min-width: 5.5rem; +} + +.task-schedule-toggle-copy { + color: var(--text-muted); +} + +.task-schedule-toggle-state { + min-width: 1.6rem; + color: var(--text-secondary); +} + /* ── Buttons ────────────────────────────────────────────────── */ .btn { display: inline-flex; diff --git a/static/js/cloud_backup.js b/static/js/cloud_backup.js index 5bc99f5..61a77f0 100644 --- a/static/js/cloud_backup.js +++ b/static/js/cloud_backup.js @@ -72,6 +72,9 @@ document.addEventListener('DOMContentLoaded', function () { const megaFolderWarning = document.getElementById('megaFolderWarning'); const rcloneConfig = document.getElementById('rcloneConfig'); const remoteName = document.getElementById('remoteName'); + const rcloneIncludePatterns = document.getElementById('rcloneIncludePatterns'); + const rcloneExcludePatterns = document.getElementById('rcloneExcludePatterns'); + const rcloneFilterFeedback = document.getElementById('rcloneFilterFeedback'); const backupTime = document.getElementById('backupTime'); const bandwidthLimit = document.getElementById('bandwidthLimit'); @@ -264,6 +267,8 @@ document.addEventListener('DOMContentLoaded', function () { function fillConfigForm(cfg) { if (!cfg) return; rcloneConfig.value = cfg.rclone_config || ''; + rcloneIncludePatterns.value = cfg.rclone_include_patterns || ''; + rcloneExcludePatterns.value = cfg.rclone_exclude_patterns || ''; if (cfg.cloud_mode === 'mega') { modeMega.checked = true; showModeFields('mega'); @@ -297,6 +302,8 @@ document.addEventListener('DOMContentLoaded', function () { data.rclone_config = rcloneConfig.value.trim(); data.remote_name = remoteName.value.trim(); } + data.rclone_include_patterns = rcloneIncludePatterns.value.trim(); + data.rclone_exclude_patterns = rcloneExcludePatterns.value.trim(); return data; } @@ -322,10 +329,25 @@ document.addEventListener('DOMContentLoaded', function () { } } + function validateRclonePatternText(textarea) { + const invalid = textarea.value.split('\n').some(line => { + const pattern = line.trim(); + return pattern && !pattern.startsWith('#') && !pattern.startsWith(';') && ['+', '-', '!'].includes(pattern[0]); + }); + textarea.classList.toggle('is-invalid', invalid); + return !invalid; + } + configForm.addEventListener('submit', function (e) { e.preventDefault(); const data = getConfigFormData(); let valid = true; + const filtersValid = validateRclonePatternText(rcloneIncludePatterns) + && validateRclonePatternText(rcloneExcludePatterns); + rcloneFilterFeedback.style.display = filtersValid ? '' : 'block'; + if (!filtersValid) { + valid = false; + } if (data.cloud_mode === 'mega') { if (!data.mega_email.match(/^[^@\s]+@[^@\s]+\.[^@\s]+$/)) { megaEmail.classList.add('is-invalid'); valid = false; @@ -391,6 +413,13 @@ document.addEventListener('DOMContentLoaded', function () { remoteName.addEventListener('blur', validateRemoteName); remoteName.addEventListener('input', validateRemoteName); } + [rcloneIncludePatterns, rcloneExcludePatterns].forEach(textarea => { + textarea.addEventListener('input', function () { + const filtersValid = validateRclonePatternText(rcloneIncludePatterns) + && validateRclonePatternText(rcloneExcludePatterns); + rcloneFilterFeedback.style.display = filtersValid ? '' : 'block'; + }); + }); if (modeMega) { modeMega.addEventListener('change', function() { diff --git a/static/js/scripts.js b/static/js/scripts.js index 38c89d2..c7295f6 100644 --- a/static/js/scripts.js +++ b/static/js/scripts.js @@ -13,6 +13,8 @@ document.addEventListener("DOMContentLoaded", function () { const statusBadge = document.getElementById("task-status-badge"); const scheduleBadge = document.getElementById("task-schedule-badge"); const manageScheduleBtn = document.getElementById("manage-schedule-btn"); + const scheduleToggle = document.getElementById("task-schedule-toggle"); + const scheduleToggleState = document.getElementById("task-schedule-toggle-state"); let currentScheduleCanEnable = manageScheduleBtn ? manageScheduleBtn.dataset.scheduleCanEnable === "true" : false; @@ -125,6 +127,13 @@ document.addEventListener("DOMContentLoaded", function () { if (manageScheduleBtn) { manageScheduleBtn.dataset.scheduleCanEnable = currentScheduleCanEnable ? "true" : "false"; } + if (scheduleToggle) { + const enabled = schedule.state === "active"; + scheduleToggle.checked = enabled; + if (scheduleToggleState) { + scheduleToggleState.textContent = enabled ? "On" : "Off"; + } + } } async function enableSchedule() { @@ -146,6 +155,31 @@ document.addEventListener("DOMContentLoaded", function () { } } + async function setScheduleEnabled(enabled) { + if (!scheduleToggle) return; + scheduleToggle.disabled = true; + try { + const response = await window.ApiClient.fetchJson( + `/task/${encodeURIComponent(taskName)}/schedule-enabled`, + { + method: "POST", + headers: { "Accept": "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }) + } + ); + updateScheduleControls(response.data && response.data.task && response.data.task.schedule); + showAlert(response.message || "Automatic runs updated.", "success"); + } catch (error) { + scheduleToggle.checked = !enabled; + if (scheduleToggleState) { + scheduleToggleState.textContent = scheduleToggle.checked ? "On" : "Off"; + } + showAlert(error.message || "Automatic runs update failed.", "danger"); + } finally { + scheduleToggle.disabled = false; + } + } + function fetchLogs() { const distanceFromBottom = logContainer ? logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight @@ -237,6 +271,12 @@ document.addEventListener("DOMContentLoaded", function () { window.ActionContextMenu.show(items, event.clientX, event.clientY); }); } + + if (scheduleToggle) { + scheduleToggle.addEventListener("change", () => { + setScheduleEnabled(scheduleToggle.checked); + }); + } } // --- Setup Wizard: Backup Config Step Logic --- diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..27f3bf1 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}{{ browser_title('Page not found') }}{% endblock %} +{% block header %}Page not found{% endblock %} + +{% block content %} +
    +
    +
    + +
    +

    This page is not here

    +

    This page does not exist, or the link is out of date.

    +
    +
    + + + +
    + Requested path + {# The path is escaped by Jinja, so odd URLs can be shown without becoming HTML. #} + {{ requested_path }} +
    +
    +
    +{% endblock %} diff --git a/templates/cloud_backup.html b/templates/cloud_backup.html index 995ca87..1837045 100644 --- a/templates/cloud_backup.html +++ b/templates/cloud_backup.html @@ -67,6 +67,23 @@

    Backup Settings

    +
    +
    + File Filters +
    +
    +
    + + +
    +
    + + +
    +
    +
    One pattern per line. These use rclone filter patterns. Blank lines and lines starting with # or ; are ignored.
    +
    Use plain rclone patterns only. Do not start lines with +, -, or !.
    +
    diff --git a/templates/dashboard.html b/templates/dashboard.html index 2fa14df..f1417de 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -97,6 +97,7 @@

    Scheduled Tasks

    Status Last Run Next Run + Automatic Runs @@ -131,6 +132,20 @@

    Scheduled Tasks

    {% set schedule_cell_class = '' %} {% endif %} {{ task.schedule.label or task.next_run or '—' }} + + {% if task.schedule_toggle_supported %} + {% set automatic_runs_enabled = task.schedule.state == 'active' %} + + {% else %} + + {% endif %} + {% endfor %} @@ -235,6 +250,10 @@

    System Actions

    return ''; } + function automaticRunsEnabled(task) { + return Boolean(task && task.schedule && task.schedule.state === 'active'); + } + function rememberTaskSchedule(tasks) { taskScheduleByName.clear(); (tasks || []).forEach((task) => { @@ -321,6 +340,40 @@

    System Actions

    tdNextRun.textContent = scheduleLabel || '—'; row.appendChild(tdNextRun); + const tdAutomaticRuns = document.createElement('td'); + if (task.schedule_toggle_supported) { + const enabled = automaticRunsEnabled(task); + const label = document.createElement('label'); + label.className = 'task-schedule-toggle-control task-schedule-toggle-control-compact'; + + const switchOuter = document.createElement('span'); + switchOuter.className = 'toggle-switch toggle-switch-sm'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'task-schedule-toggle'; + input.dataset.taskName = task.name; + input.checked = enabled; + input.setAttribute('aria-label', `Automatic runs for ${task.name}`); + const slider = document.createElement('span'); + slider.className = 'toggle-slider'; + switchOuter.appendChild(input); + switchOuter.appendChild(slider); + + const state = document.createElement('span'); + state.className = 'task-schedule-toggle-state'; + state.textContent = enabled ? 'On' : 'Off'; + + label.appendChild(switchOuter); + label.appendChild(state); + tdAutomaticRuns.appendChild(label); + } else { + const dash = document.createElement('span'); + dash.className = 'text-muted'; + dash.textContent = '—'; + tdAutomaticRuns.appendChild(dash); + } + row.appendChild(tdAutomaticRuns); + return row; } @@ -364,7 +417,10 @@

    System Actions

    function bindInteractiveRows() { document.querySelectorAll('.clickable-row[data-href]').forEach((row) => { - row.addEventListener('click', () => { + row.addEventListener('click', (event) => { + if (event.target.closest('a, button, input, label, select, textarea')) { + return; + } window.location.href = row.dataset.href; }); row.addEventListener('keydown', (event) => { @@ -377,6 +433,13 @@

    System Actions

    window.ActionContextMenu.bind(row, () => dashboardTaskActionItems(row.dataset.taskName)); } }); + + document.querySelectorAll('.task-schedule-toggle').forEach((toggle) => { + if (toggle.dataset.scheduleToggleBound === 'true') return; + toggle.dataset.scheduleToggleBound = 'true'; + toggle.addEventListener('click', (event) => event.stopPropagation()); + toggle.addEventListener('change', () => setTaskScheduleEnabled(toggle)); + }); } function applyRelativeTaskTimes() { @@ -453,6 +516,29 @@

    System Actions

    setTimeout(updateTaskSchedule, 1500); } + async function setTaskScheduleEnabled(toggle) { + const taskName = toggle.dataset.taskName; + const enabled = toggle.checked; + const stateLabel = toggle.closest('.task-schedule-toggle-control')?.querySelector('.task-schedule-toggle-state'); + toggle.disabled = true; + try { + const { message } = await window.ApiClient.fetchJson(`/task/${encodeURIComponent(taskName)}/schedule-enabled`, { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }); + if (stateLabel) stateLabel.textContent = enabled ? 'On' : 'Off'; + showAlert(message || 'Automatic runs updated.', 'success'); + updateTaskSchedule(); + } catch (error) { + toggle.checked = !enabled; + if (stateLabel) stateLabel.textContent = toggle.checked ? 'On' : 'Off'; + showAlert(error.message || 'Automatic runs update failed.', 'danger'); + } finally { + toggle.disabled = false; + } + } + function bindDashboardActionForms() { document.querySelectorAll('.dashboard-action-form').forEach((form) => { if (form.dataset.dashboardActionBound === 'true') { diff --git a/templates/task_detail.html b/templates/task_detail.html index 81495b9..e32ca86 100644 --- a/templates/task_detail.html +++ b/templates/task_detail.html @@ -46,6 +46,18 @@ {{ task_summary.schedule.label }} + {% if task.schedule_toggle_supported %} + {% set automatic_runs_enabled = task_summary.schedule.state == 'active' %} + + {% endif %} +