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
5 changes: 3 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Build and Publish Docker Image (GHCR)

on:
push:
branches: ["main"]
branches: ["main", "next"]
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
76 changes: 71 additions & 5 deletions app/app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -38,6 +52,8 @@
scheduler = BackgroundScheduler()
backup_status_lock = threading.Lock()
backup_status = {}
_scheduler_lock_handle = None
_scheduler_started = False


def load_devices():
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()))
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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")))
50 changes: 47 additions & 3 deletions app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -217,7 +238,12 @@
<img class="brand-icon" src="/static/icon.png" alt="S/FTP Backup icon" onerror="this.style.display='none'" />
<h1>S/FTP Backup Scheduler</h1>
</div>
<button type="button" class="theme-toggle" id="theme-toggle" aria-pressed="false">Dark mode</button>
<div class="header-actions">
<a class="support-link" href="https://buymeacoffee.com/arcreactorkc" target="_blank" rel="noopener noreferrer">
Buy me a coffee
</a>
<button type="button" class="theme-toggle" id="theme-toggle" aria-pressed="false">Dark mode</button>
</div>
</div>
<p>Enter a device label, IP address, FTP/SFTP credentials, and backup interval. Use “Add device” to include multiple devices.</p>

Expand Down Expand Up @@ -313,6 +339,21 @@ <h2>Configured Devices</h2>
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');
Expand Down Expand Up @@ -404,6 +445,7 @@ <h2>Configured Devices</h2>
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
Expand Down Expand Up @@ -580,12 +622,13 @@ <h2>Configured Devices</h2>
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';
Expand Down Expand Up @@ -694,6 +737,7 @@ <h2>Configured Devices</h2>
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')
Expand Down
5 changes: 3 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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