From dd53c7e792409589746ad0008dc9aeb2d9d12876 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:47:33 +0200 Subject: [PATCH 1/6] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 353c112..8f9e584 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@
Open for sort of CHANGELOGS? +**V1.02 - cap-drop=all** +Added the option to make use of --cap-drop=ALL as long asd you specific an user in an parameter ( --user xx:xx) **V1.01 - UI** UI Overhaul From 718fc97b8fe598824952a71bd7ba2dd7d0bce389 Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:49:43 +0200 Subject: [PATCH 2/6] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f9e584..12e9495 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Open for sort of CHANGELOGS? **V1.02 - cap-drop=all** -Added the option to make use of --cap-drop=ALL as long asd you specific an user in an parameter ( --user xx:xx) +Added the option to make use of --cap-drop=ALL as long as you specify an user in an parameter ( --user xx:xx) **V1.01 - UI** UI Overhaul From ab46d55653eacf034ac9fc6391e4dde06fd18e81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:04:44 +0000 Subject: [PATCH 3/6] Bump sqlalchemy from 2.0.52 to 2.0.54 Bumps [sqlalchemy](https://github.com/sqlalchemy/sqlalchemy) from 2.0.52 to 2.0.54. - [Release notes](https://github.com/sqlalchemy/sqlalchemy/releases) - [Changelog](https://github.com/sqlalchemy/sqlalchemy/blob/main/CHANGES.rst) - [Commits](https://github.com/sqlalchemy/sqlalchemy/commits) --- updated-dependencies: - dependency-name: sqlalchemy dependency-version: 2.0.54 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3f01d7c..df4d5e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ PyGithub==2.10.0 gitpython==3.1.62 python-dotenv==1.2.3 requests==2.34.2 -sqlalchemy==2.0.52 +sqlalchemy==2.0.54 flask-sqlalchemy==3.1.1 wtforms==3.2.2 gunicorn==26.2.0 From 4348ea78080445d28df7aa81fdea99e4f117845d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:35:58 +0000 Subject: [PATCH 4/6] Add per-repository option to back up GitHub releases Each repository can now also back up release files: none (default, unchanged behaviour), the latest release, the last 5 releases, or all releases. For each release the uploaded assets and the source code zip are stored in a _releases// folder inside the backup. Draft releases are skipped. init_db.py adds the new release_mode column to existing databases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWkh86yGwethdcSzje555s --- app.py | 13 +++- backup_service.py | 106 ++++++++++++++++++++++++++++++++- init_db.py | 11 +++- models.py | 9 +++ templates/add_by_username.html | 15 ++++- templates/add_repository.html | 15 ++++- templates/edit_repository.html | 16 ++++- templates/repositories.html | 12 +++- 8 files changed, 188 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index 2eeb2fe..a185935 100644 --- a/app.py +++ b/app.py @@ -10,7 +10,7 @@ from apscheduler.triggers.cron import CronTrigger import logging from backup_service import BackupService -from models import db, User, Repository, BackupJob, PasswordResetCode +from models import db, User, Repository, BackupJob, PasswordResetCode, RELEASE_MODES import atexit # Configure logging @@ -316,6 +316,11 @@ def ensure_scheduler_initialized(): _scheduler_initialized = True logger.info("Scheduler initialization completed") +def get_release_mode_from_form(): + """Read the release backup mode from the submitted form, defaulting to no releases""" + release_mode = request.form.get('release_mode', 'none') + return release_mode if release_mode in RELEASE_MODES else 'none' + @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) @@ -538,6 +543,7 @@ def add_repository(): backup_format=backup_format, schedule_type=schedule_type, retention_count=retention_count, + release_mode=get_release_mode_from_form(), custom_interval=custom_interval, custom_unit=custom_unit, custom_hour=custom_hour, @@ -566,7 +572,8 @@ def add_repositories_by_username(): backup_format = request.form.get('backup_format', 'folder') schedule_type = request.form.get('schedule_type', 'daily') retention_count = int(request.form.get('retention_count', 5)) - + release_mode = get_release_mode_from_form() + if not github_username: flash('Please provide a GitHub username', 'error') return render_template('add_by_username.html') @@ -637,6 +644,7 @@ def add_repositories_by_username(): backup_format=backup_format, schedule_type=schedule_type, retention_count=retention_count, + release_mode=release_mode, is_active=True ) @@ -714,6 +722,7 @@ def edit_repository(repo_id): repository.backup_format = request.form['backup_format'] repository.schedule_type = request.form['schedule_type'] repository.retention_count = int(request.form['retention_count']) + repository.release_mode = get_release_mode_from_form() repository.is_active = 'is_active' in request.form # Handle custom schedule fields diff --git a/backup_service.py b/backup_service.py index e335591..6bab580 100644 --- a/backup_service.py +++ b/backup_service.py @@ -1,18 +1,23 @@ import os +import re import git import shutil +import requests import zipfile import tarfile import logging from datetime import datetime, timedelta from pathlib import Path from github import Github -from models import db, BackupJob +from models import db, BackupJob, RELEASE_MODES from urllib.parse import urlparse logger = logging.getLogger(__name__) class BackupService: + # Folder inside each backup that holds downloaded release files + RELEASES_DIR_NAME = '_releases' + def __init__(self): self.backup_base_dir = Path('/app/backups') self.backup_base_dir.mkdir(exist_ok=True) @@ -153,7 +158,10 @@ def backup_repository(self, repository): temp_clone_dir.mkdir(parents=True, exist_ok=False) self._clone_repository(repository, temp_clone_dir) - + + # Download release files into the backup if enabled for this repository + self._download_releases(repository, temp_clone_dir / self.RELEASES_DIR_NAME) + # Create backup in specified format backup_path = self._create_backup( temp_clone_dir, @@ -290,6 +298,100 @@ def _clone_repository(self, repository, clone_dir): pass raise e + def _parse_owner_repo(self, repo_url): + """Return (owner, repo_name) for a GitHub URL, or None if it is not a GitHub URL""" + if repo_url.startswith('git@'): + # git@github.com:owner/repo.git + host, _, path = repo_url[len('git@'):].partition(':') + else: + parsed = urlparse(repo_url) + host, path = parsed.hostname or '', parsed.path + + if host.lower() != 'github.com': + return None + + path_parts = path.strip('/').split('/') + if len(path_parts) < 2: + return None + + repo_name = path_parts[1] + if repo_name.endswith('.git'): + repo_name = repo_name[:-4] + return path_parts[0], repo_name + + def _safe_filename(self, name): + """Make a release tag or asset name safe to use as a single path component""" + safe = re.sub(r'[^A-Za-z0-9._-]', '_', name).strip('.') + return safe or 'unnamed' + + def _download_file(self, url, destination, headers): + """Stream a file from the GitHub API to disk""" + with requests.get(url, headers=headers, stream=True, timeout=(30, 300), allow_redirects=True) as response: + response.raise_for_status() + with open(destination, 'wb') as f: + for chunk in response.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + + def _download_releases(self, repository, releases_dir): + """Download release assets and source archives according to the repository's release mode""" + release_mode = repository.release_mode or 'none' + if release_mode not in RELEASE_MODES: + logger.warning(f"Unknown release mode '{release_mode}' for {repository.name}, skipping releases") + return + if release_mode == 'none': + return + + owner_repo = self._parse_owner_repo(repository.url) + if not owner_repo: + raise Exception(f"Cannot back up releases: not a GitHub repository URL: {repository.url}") + owner, repo_name = owner_repo + + token = repository.github_token.strip() if repository.github_token else '' + g = Github(token) if token else Github() + gh_repo = g.get_repo(f"{owner}/{repo_name}") + + # The API returns releases newest first; drafts are not published releases + limit = RELEASE_MODES[release_mode] + releases = [] + for release in gh_repo.get_releases(): + if release.draft: + continue + releases.append(release) + if limit is not None and len(releases) >= limit: + break + + if not releases: + logger.info(f"No releases found for {repository.name}") + return + + logger.info(f"Downloading {len(releases)} release(s) for {repository.name} (mode: {release_mode})") + releases_dir.mkdir(parents=True, exist_ok=True) + + base_headers = {'User-Agent': 'GithubBackup-docker'} + if token: + base_headers['Authorization'] = f'token {token}' + + for release in releases: + tag = release.tag_name or f"release_{release.id}" + release_dir = releases_dir / self._safe_filename(tag) + release_dir.mkdir(parents=True, exist_ok=True) + + # Source code archive of the tagged commit + if release.zipball_url: + self._download_file( + release.zipball_url, + release_dir / f"{self._safe_filename(repo_name)}-{self._safe_filename(tag)}-source.zip", + base_headers + ) + + # Uploaded release assets (binaries, packages, etc.) + asset_headers = dict(base_headers, Accept='application/octet-stream') + for asset in release.get_assets(): + self._download_file(asset.url, release_dir / self._safe_filename(asset.name), asset_headers) + + logger.info(f"Downloaded release {tag} for {repository.name}") + def _cleanup_temp_directories(self, repo_backup_dir): """Clean up old temporary directories that might be left behind""" try: diff --git a/init_db.py b/init_db.py index 42e3584..67b4993 100644 --- a/init_db.py +++ b/init_db.py @@ -20,7 +20,16 @@ def ensure_sqlite_path(): try: # Create tables if they don't exist db.create_all() - + + # Add columns introduced after the initial schema to existing databases + from sqlalchemy import inspect, text + repo_columns = {col['name'] for col in inspect(db.engine).get_columns('repository')} + if 'release_mode' not in repo_columns: + with db.engine.begin() as conn: + conn.execute(text("ALTER TABLE repository ADD COLUMN release_mode VARCHAR(10) DEFAULT 'none'")) + conn.execute(text("UPDATE repository SET release_mode = 'none' WHERE release_mode IS NULL")) + print("Added release_mode column to repository table") + # Update existing users without theme to use dark theme from models import User users_without_theme = User.query.filter_by(theme=None).all() diff --git a/models.py b/models.py index c73a11c..5aabaa6 100644 --- a/models.py +++ b/models.py @@ -4,6 +4,14 @@ db = SQLAlchemy() +# Release backup modes: how many of the most recent releases to include (None = all) +RELEASE_MODES = { + 'none': 0, + 'latest': 1, + 'last5': 5, + 'all': None, +} + class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) @@ -24,6 +32,7 @@ class Repository(db.Model): backup_format = db.Column(db.String(20), default='folder') # folder, zip, tar.gz schedule_type = db.Column(db.String(20), default='daily') # manual, hourly, daily, weekly, monthly, custom retention_count = db.Column(db.Integer, default=5) # Number of backups to keep + release_mode = db.Column(db.String(10), default='none') # none, latest, last5, all # Custom schedule fields custom_interval = db.Column(db.Integer) # For custom schedule: interval value (e.g., 3 for "every 3 days") custom_unit = db.Column(db.String(10)) # For custom schedule: unit (days, weeks, months) diff --git a/templates/add_by_username.html b/templates/add_by_username.html index aa0189b..d8d216b 100644 --- a/templates/add_by_username.html +++ b/templates/add_by_username.html @@ -87,7 +87,20 @@

Default number of backup versions to keep for each repository (can be changed per-repo later) - + +
+ + +
+ Default release backup setting for all added repositories (can be changed per-repo later) +
+
+
How this works: diff --git a/templates/add_repository.html b/templates/add_repository.html index 6605854..d5a1701 100644 --- a/templates/add_repository.html +++ b/templates/add_repository.html @@ -97,7 +97,20 @@

Number of backup versions to keep (older backups will be automatically deleted)

- + +
+ + +
+ Also include release assets and source archives in each backup (stored in a _releases folder) +
+
+
Note: diff --git a/templates/edit_repository.html b/templates/edit_repository.html index 1bd911b..3507c55 100644 --- a/templates/edit_repository.html +++ b/templates/edit_repository.html @@ -99,7 +99,21 @@

- + +
+ + {% set release_mode = repository.release_mode or 'none' %} + +
+ Also include release assets and source archives in each backup (stored in a _releases folder) +
+
+
- + + {% if repo.release_mode and repo.release_mode != 'none' %} +

+ + + Releases: + {% if repo.release_mode == 'latest' %}latest release{% elif repo.release_mode == 'last5' %}last 5 releases{% else %}all releases{% endif %} + +

+ {% endif %} + {% if repo.last_backup %}

From 9822912952d002dc0e66ae6bcaf36db21b5b5da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:40:22 +0000 Subject: [PATCH 5/6] Add search and sorting to the Repositories page Repositories can be sorted by date added, repository name, owner (A-Z / Z-A) or last backup time (newest / oldest first; never backed up repositories go last). A search box filters on repository name and owner, including "owner/name". Filtering and sorting happen in the browser, and the last used sort and search are remembered. Adds a Repository.owner property derived from the URL, which is also shown on each repository card. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWkh86yGwethdcSzje555s --- app.py | 4 +- models.py | 13 +++++ templates/repositories.html | 108 +++++++++++++++++++++++++++++++++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index a185935..398a640 100644 --- a/app.py +++ b/app.py @@ -450,8 +450,8 @@ def reset_password(): @app.route('/repositories') @login_required def repositories(): - repos = Repository.query.filter_by(user_id=current_user.id).all() - + repos = Repository.query.filter_by(user_id=current_user.id).order_by(Repository.id).all() + # Get backup job status today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) running_jobs = BackupJob.query.filter_by(user_id=current_user.id, status='running').all() diff --git a/models.py b/models.py index 5aabaa6..baf7a06 100644 --- a/models.py +++ b/models.py @@ -44,6 +44,19 @@ class Repository(db.Model): backup_jobs = db.relationship('BackupJob', backref='repository', lazy=True, cascade='all, delete-orphan') + @property + def owner(self): + """GitHub owner (user or organization) taken from the repository URL""" + url = (self.url or '').strip() + try: + if url.startswith('git@'): + # git@github.com:owner/repo.git + return url.split(':', 1)[1].split('/')[0] + # https://github.com/owner/repo(.git) + return url.rstrip('/').split('/')[-2] + except IndexError: + return '' + class BackupJob(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) diff --git a/templates/repositories.html b/templates/repositories.html index 60730f9..0c0f6bc 100644 --- a/templates/repositories.html +++ b/templates/repositories.html @@ -136,9 +136,46 @@

Repositories

{% if repositories %} -
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + + +
{% for repo in repositories %} -
+
@@ -155,6 +192,9 @@

+ {% if repo.owner %} + {{ repo.owner }}
+ {% endif %} {{ repo.url }}

@@ -306,6 +346,70 @@

No Repositories

{% block scripts %}