diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 07c9956..a7e8c7f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Publish Docker Image (GHCR) on: push: - branches: ["main"] + branches: ["main", "next"] workflow_dispatch: permissions: @@ -41,7 +41,8 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=latest + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} type=sha,format=short - name: Build and push diff --git a/Dockerfile b/Dockerfile index 8cb9d80..6e622d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,4 +16,4 @@ ENV DATA_DIR=/data \ DEVICE_DB=/data/devices.json \ BACKUP_OUTPUT_DIR=/backups -CMD ["python", "/app/app/app.py"] +CMD ["gunicorn", "-w", "2", "-k", "gthread", "--threads", "4", "-b", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-", "--chdir", "/app/app", "app:app"] diff --git a/app/app.py b/app/app.py index f27db72..b910bd0 100644 --- a/app/app.py +++ b/app/app.py @@ -1,14 +1,27 @@ +import fcntl import json import os import shutil import stat import tempfile import threading +import warnings +from datetime import datetime, timezone from ftplib import FTP, all_errors as ftp_errors -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional +from cryptography.utils import CryptographyDeprecationWarning + +warnings.filterwarnings( + "ignore", + message=( + r".*TripleDES has been moved to cryptography\.hazmat\.decrepit\.ciphers\.algorithms\.TripleDES.*" + ), + category=CryptographyDeprecationWarning, +) + import paramiko from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -29,6 +42,7 @@ BACKUP_OUTPUT_DIR = Path(os.getenv("BACKUP_OUTPUT_DIR", "/backups")) SFTP_PORT_DEFAULT = int(os.getenv("SFTP_PORT", "22")) FTP_PORT_DEFAULT = int(os.getenv("FTP_PORT", "21")) +DEFAULT_MAX_BACKUPS = 10 DATA_DIR.mkdir(parents=True, exist_ok=True) BACKUP_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) @@ -38,6 +52,8 @@ scheduler = BackgroundScheduler() backup_status_lock = threading.Lock() backup_status = {} +_scheduler_lock_handle = None +_scheduler_started = False def load_devices(): @@ -255,6 +271,23 @@ def create_backup(device: dict, status_callback=None): base_name = str((BACKUP_OUTPUT_DIR / folder_name).with_suffix("")) shutil.make_archive(base_name, "zip", temp_path) + max_backups = int(device.get("max_backups") or DEFAULT_MAX_BACKUPS) + prune_old_backups(device["label"], max_backups) + + +def prune_old_backups(label: str, max_backups: int): + safe_max = max(1, min(int(max_backups), 100)) + backups = sorted( + BACKUP_OUTPUT_DIR.glob(f"{label}-*.zip"), + key=lambda path: path.stat().st_mtime, + ) + while len(backups) > safe_max: + oldest = backups.pop(0) + try: + oldest.unlink() + except FileNotFoundError: + continue + def _job_id_for_device(device: dict) -> str: # Keep this stable; it’s how we look up next_run_time @@ -320,8 +353,12 @@ def schedule_device(device: dict): if not seconds: return start_date = _parse_iso_datetime(device.get("next_run_at")) - if start_date and start_date <= datetime.now(): - start_date = None + if start_date: + now = datetime.now(tz=start_date.tzinfo) if start_date.tzinfo else datetime.now() + if start_date <= now: + start_date = None + elif start_date.tzinfo is None: + start_date = start_date.replace(tzinfo=timezone.utc) job = scheduler.add_job( run_backup_and_record, @@ -343,6 +380,29 @@ def refresh_schedule(): save_devices(devices_list) +def start_scheduler(): + global _scheduler_lock_handle, _scheduler_started + if _scheduler_started: + return + lock_path = DATA_DIR / "scheduler.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + lock_file = lock_path.open("w") + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + lock_file.close() + return + _scheduler_lock_handle = lock_file + refresh_schedule() + scheduler.start() + _scheduler_started = True + + +@app.before_request +def _start_scheduler_once(): + start_scheduler() + + @app.route("/") def index(): return render_template("index.html", intervals=sorted(INTERVAL_SECONDS.keys())) @@ -373,6 +433,12 @@ def devices(): protocol = str(device.get("protocol") or "sftp").lower() port_default = FTP_PORT_DEFAULT if protocol == "ftp" else SFTP_PORT_DEFAULT port = int(device.get("port") or port_default) + max_backups_raw = device.get("max_backups") + try: + max_backups = int(max_backups_raw) if max_backups_raw is not None else DEFAULT_MAX_BACKUPS + except (TypeError, ValueError): + max_backups = DEFAULT_MAX_BACKUPS + max_backups = max(1, min(max_backups, 100)) if ( not label @@ -395,6 +461,7 @@ def devices(): "protocol": protocol, "port": port, "paths": cleaned_paths, + "max_backups": max_backups, } existing = existing_devices.get(_device_key(cleaned_device)) @@ -479,6 +546,5 @@ def browse(): if __name__ == "__main__": - refresh_schedule() - scheduler.start() + start_scheduler() app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000"))) diff --git a/app/templates/index.html b/app/templates/index.html index 96bbffb..7dfb93f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -66,9 +66,30 @@ border-radius: 999px; font-weight: 600; } + .header-actions { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + } + .support-link { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--button-secondary-bg); + color: var(--button-secondary-text); + text-decoration: none; + font-weight: 600; + } + .support-link:hover { + background: var(--hover); + } .device-row { display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 0.8fr 1.2fr auto; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 0.8fr 1fr 0.8fr 1.2fr auto; gap: 1rem; margin-bottom: 1rem; align-items: end; @@ -217,7 +238,12 @@ S/FTP Backup icon

S/FTP Backup Scheduler

- +
+ + Buy me a coffee + + +

Enter a device label, IP address, FTP/SFTP credentials, and backup interval. Use “Add device” to include multiple devices.

@@ -313,6 +339,21 @@

Configured Devices

return wrapper; } + function createMaxBackupsField(value) { + const wrapper = document.createElement('div'); + const label = document.createElement('label'); + label.textContent = 'Max backups kept *'; + const input = document.createElement('input'); + input.type = 'number'; + input.name = 'max_backups'; + input.required = true; + input.min = '1'; + input.max = '100'; + input.value = value || '10'; + wrapper.append(label, input); + return wrapper; + } + function createProtocolField(value) { const wrapper = document.createElement('div'); const label = document.createElement('label'); @@ -404,6 +445,7 @@

Configured Devices

row.appendChild(createInputField('Username *', 'text', 'username', device.username)); row.appendChild(createInputField('Password *', 'password', 'password', device.password)); row.appendChild(createSelectField('Backup interval *', 'interval', device.interval)); + row.appendChild(createMaxBackupsField(device.max_backups)); row.appendChild(createProtocolField(device.protocol)); // Paths textarea @@ -580,12 +622,13 @@

Configured Devices

const pathsSummary = (device.paths && device.paths.length) ? ` | Paths: ${device.paths.length}` : ' | Paths: default'; + const maxBackupsSummary = ` | Keep: ${device.max_backups || 10}`; const nextText = formatNextBackup(device.next_backup); const protocolLabel = (device.protocol || 'sftp').toUpperCase(); - info.textContent = `${device.label} (${device.ip}) — ${device.username} — ${device.interval} — ${protocolLabel} | ${nextText}${pathsSummary}`; + info.textContent = `${device.label} (${device.ip}) — ${device.username} — ${device.interval} — ${protocolLabel} | ${nextText}${pathsSummary}${maxBackupsSummary}`; const actions = document.createElement('div'); actions.className = 'device-actions'; @@ -694,6 +737,7 @@

Configured Devices

username: row.querySelector('input[name="username"]').value, password: row.querySelector('input[name="password"]').value, interval: row.querySelector('select[name="interval"]').value, + max_backups: row.querySelector('input[name="max_backups"]').value, protocol: row.querySelector('select[name="protocol"]').value, paths: row.querySelector('textarea[name="paths"]').value .split('\n') diff --git a/requirements.txt b/requirements.txt index 5deb0e4..9f61c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -Flask==3.0.2 +Flask==3.0.3 APScheduler==3.10.4 -paramiko==3.4.0 +paramiko==3.5.0 +gunicorn==22.0.0