From 8b46b747deeb1411ca6033d59154a3d8b04fd953 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Sun, 19 Apr 2026 20:30:55 +0200 Subject: [PATCH 01/16] Fix typo in landing page --- community_backup/webui/templates/landing.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community_backup/webui/templates/landing.html b/community_backup/webui/templates/landing.html index b7a8141..a9960f9 100644 --- a/community_backup/webui/templates/landing.html +++ b/community_backup/webui/templates/landing.html @@ -6,7 +6,7 @@ -

This is a service offering space for backups free of charge. This is a hobby project. It comes with no garantees for anything, especially availability of the service. This service is in a testing phase at the moment. It might be unavailable at times and features might change quickly.

+

This is a service offering space for backups free of charge. This is a hobby project. It comes with no guarantees for anything, especially availability of the service. This service is in a testing phase at the moment. It might be unavailable at times and features might change quickly.

Currently this service offers only backups via Borg Backup.

To access this service you need an account. To register for an account you need a voucher. Vouchers are required to control the amount of users, so that there is enough space available for everyone.

From 02fad76482e5e18032a22680133028f8b1ebc264 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Sun, 19 Apr 2026 20:34:07 +0200 Subject: [PATCH 02/16] reworked SSH Key handling --- community_backup/tests/__init__.py | 0 community_backup/tests/test_models.py | 54 +++++++++++++++++++ community_backup/webui/deployments.py | 4 +- community_backup/webui/forms.py | 2 +- community_backup/webui/models.py | 45 ++++++++++++++-- .../webui/templates/borg_list.html | 2 +- 6 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 community_backup/tests/__init__.py create mode 100644 community_backup/tests/test_models.py diff --git a/community_backup/tests/__init__.py b/community_backup/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/community_backup/tests/test_models.py b/community_backup/tests/test_models.py new file mode 100644 index 0000000..74e2aa4 --- /dev/null +++ b/community_backup/tests/test_models.py @@ -0,0 +1,54 @@ +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from webui.models import BorgRepository + + +class BorgRepositoryTestCase(TestCase): + def test_ssh_key_regex(self): + user = User.objects.create(username="foo") + + # good repo + repo = BorgRepository.objects.create( + name="test1", + key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3 jo@hubris", + user=user, + ) + + repo.full_clean() + + # good repo, key without comment + repo = BorgRepository.objects.create( + name="test2", + key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3", + user=user, + ) + repo.full_clean() + + # bad repo (wrong key) + repo = BorgRepository.objects.create(name="test3", key="Foo", user=user) + self.assertRaises(ValidationError, repo.full_clean) + + # bad repo (multiple valid keys ) + repo = BorgRepository.objects.create( + name="test4", + key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3 jo@hubris\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3 jo@hubris", + user=user, + ) + self.assertRaises(ValidationError, repo.full_clean) + + # bad repo, leading whitespace + repo = BorgRepository.objects.create( + name="test5", + key=" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3 jo@hubris", + user=user, + ) + self.assertRaises(ValidationError, repo.full_clean) + + # good repo, with whitespace in commment + repo = BorgRepository.objects.create( + name="test6", + key="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOYOkeGl40Bss9LMmreCtzq+uXw4IQ/E5SKsBRcKAfF3 jo@hubris whitespace in comment", + user=user, + ) + repo.full_clean() diff --git a/community_backup/webui/deployments.py b/community_backup/webui/deployments.py index b56d1bc..527dda3 100644 --- a/community_backup/webui/deployments.py +++ b/community_backup/webui/deployments.py @@ -8,8 +8,6 @@ from django.conf import settings logger = getLogger("deployments") -# sync users - def sync_repos(dry_run=False): """Synchronize the repos""" @@ -19,7 +17,7 @@ def sync_repos(dry_run=False): repos_by_key = defaultdict(list) for repo in repos: - repos_by_key[repo.key].append(repo) + repos_by_key[f"{repo.key_type} {repo.key_value}"].append(repo) # create .ssh directory ssh_dir = settings.BACKUP_AUTHORIZED_KEYS.parent diff --git a/community_backup/webui/forms.py b/community_backup/webui/forms.py index 99e774c..3ac0cb0 100644 --- a/community_backup/webui/forms.py +++ b/community_backup/webui/forms.py @@ -10,7 +10,7 @@ class BorgRepositoryForm(forms.ModelForm): class Meta: fields = ["name", "key", "user"] model = BorgRepository - widgets = {"user": forms.HiddenInput()} + widgets = {"user": forms.HiddenInput(), "key": forms.TextInput()} class RegisterUserForm(UserCreationForm): diff --git a/community_backup/webui/models.py b/community_backup/webui/models.py index 75c1962..d04785a 100644 --- a/community_backup/webui/models.py +++ b/community_backup/webui/models.py @@ -21,7 +21,7 @@ class BorgRepository(models.Model): key = models.TextField( validators=[ RegexValidator( - r"(ssh\-rsa|ecdsa\-sha2\-nistp256|ssh\-ed25519) ([a-zA-Z0-9\+/=]+) (\S*)", + r"^(ssh\-rsa|ecdsa\-sha2\-nistp256|ssh\-ed25519) ([a-zA-Z0-9\+/=]+)( ([\S ]*))?$", message="not a valid SSH public key.", ), ] @@ -46,9 +46,9 @@ class BorgRepository(models.Model): def repo_url(self) -> str: return f"{settings.BACKUP_USER}@{settings.BACKUP_REPO_HOST}:{self.user.pk}/{self.pk}" - def save(self): + def save(self, *args, **kwargs): - super().save() + super().save(*args, **kwargs) update_user.enqueue() def delete(self): @@ -56,6 +56,45 @@ class BorgRepository(models.Model): super().delete() update_user.enqueue() + @property + def key_type(self) -> str: + return self.key.split(" ", 1)[0].strip() + + @property + def key_value(self) -> str: + return self.key.split(" ", 2)[1].strip() + + @property + def key_comment(self) -> str | None: + splits = self.key.split(" ", 2) + if len(splits) == 3: + return splits[2].strip() + return None + + def truncated_key(self, length: int = 60): + """ + Returns a truncated version of the key for display purposes. + The Key type and the comment are retained as much as possible. + """ + + # check how long the truncated part of the key may be at most. + max_len = length - len(self.key_type) - 1 + if self.key_comment: + max_len = max_len - len(self.key_comment) - 1 + + if len(self.key_value) > max_len: + max_len -= 1 # remove one character if we add an ellipsis + trunc_key = self.key_value[:max_len] + "…" + else: + trunc_key = self.key_value + + output = f"{self.key_type} {trunc_key}" + + if self.key_comment: + output += f" {self.key_comment}" + + return output + class Voucher(models.Model): used = models.BooleanField(default=False) diff --git a/community_backup/webui/templates/borg_list.html b/community_backup/webui/templates/borg_list.html index b193fcd..cd508d4 100644 --- a/community_backup/webui/templates/borg_list.html +++ b/community_backup/webui/templates/borg_list.html @@ -24,7 +24,7 @@ {% for repo in object_list %} {{ repo.name }} - {{ repo.key | truncatechars:40 }} + {{ repo.truncated_key }} {{ repo.repo_url}} Edit Delete From 3e84c036f7f26bedd27158bb44a2ca111d0c0b1a Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Sun, 19 Apr 2026 20:35:07 +0200 Subject: [PATCH 03/16] release version 0.7 --- community_backup/community_backup/settings.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/community_backup/community_backup/settings.py b/community_backup/community_backup/settings.py index f404ca3..154ea3c 100644 --- a/community_backup/community_backup/settings.py +++ b/community_backup/community_backup/settings.py @@ -115,7 +115,7 @@ TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBacken STATIC_ROOT = BASE_DIR.parent.parent / "static" -RELEASE_VERSION = "0.6" +RELEASE_VERSION = "0.7" # Import settings from configuration.py try: diff --git a/pyproject.toml b/pyproject.toml index 526d5d5..9e552ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "community_backup" -version = "0.6" +version = "0.7" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" From 755e313c61941322051a876d04880ef9db702500 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Mon, 20 Apr 2026 14:08:02 +0200 Subject: [PATCH 04/16] added missing migrations --- .../0005_alter_borgrepository_key.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 community_backup/webui/migrations/0005_alter_borgrepository_key.py diff --git a/community_backup/webui/migrations/0005_alter_borgrepository_key.py b/community_backup/webui/migrations/0005_alter_borgrepository_key.py new file mode 100644 index 0000000..8f7f15c --- /dev/null +++ b/community_backup/webui/migrations/0005_alter_borgrepository_key.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-04-20 12:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webui", "0004_alter_borgrepository_key_alter_borgrepository_name"), + ] + + operations = [ + migrations.AlterField( + model_name="borgrepository", + name="key", + field=models.TextField( + validators=[ + django.core.validators.RegexValidator( + "^(ssh\\-rsa|ecdsa\\-sha2\\-nistp256|ssh\\-ed25519) ([a-zA-Z0-9\\+/=]+)( ([\\S ]*))?$", + message="not a valid SSH public key.", + ) + ] + ), + ), + ] From 3a4f5a03945dfca36efe2a9ac62a0f8081c09685 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Mon, 20 Apr 2026 19:03:17 +0200 Subject: [PATCH 05/16] Added Repository Quota setting --- README.md | 4 +++ .../community_backup/example_configuration.py | 1 + community_backup/community_backup/settings.py | 2 ++ community_backup/webui/deployments.py | 4 ++- .../management/commands/update_used_quota.py | 28 +++++++++++++++++++ ...ository_quota_borgrepository_used_quota.py | 23 +++++++++++++++ .../0007_alter_borgrepository_used_quota.py | 18 ++++++++++++ community_backup/webui/models.py | 19 +++++++++++++ .../webui/templates/borg_list.html | 4 ++- pyproject.toml | 1 + 10 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 community_backup/webui/management/commands/update_used_quota.py create mode 100644 community_backup/webui/migrations/0006_borgrepository_quota_borgrepository_used_quota.py create mode 100644 community_backup/webui/migrations/0007_alter_borgrepository_used_quota.py diff --git a/README.md b/README.md index f5b384d..8209c94 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,7 @@ Django debug setting. ### MARKDOWN_PAGE_DIR `MARKDOWN_PAGE_DIR` is the directory where customized markdown files are put. This directory is required. + +### BACKUP_MANAGE_PY + +`BACKUP_MANAGE_PY` is the command to run the `manage.py` file, including e.g. a Python interpreter, venv, etc. This must be a `pathlib.Path` diff --git a/community_backup/community_backup/example_configuration.py b/community_backup/community_backup/example_configuration.py index 1ddb2ac..6ad812f 100644 --- a/community_backup/community_backup/example_configuration.py +++ b/community_backup/community_backup/example_configuration.py @@ -21,3 +21,4 @@ SECRET_KEY = "change me!" # DEBUG = True MARKDOWN_PAGE_DIR = Path("./custom_md/") +BACKUP_MANAGE_PY = Path("/path/to/venv/bin/python /path/to/community_backup/manage.py") diff --git a/community_backup/community_backup/settings.py b/community_backup/community_backup/settings.py index 154ea3c..0a7f6d1 100644 --- a/community_backup/community_backup/settings.py +++ b/community_backup/community_backup/settings.py @@ -131,6 +131,7 @@ try: "The BACKUP_AUTHORIZED_KEYS setting is required." ) assert module.MARKDOWN_PAGE_DIR, "The MARKDOWN_PAGE_DIR setting is required." + assert module.BACKUP_MANAGE_PY, "The BACKUP_MANAGE_PY setting is required" except ModuleNotFoundError: print(f"could not find configuration file {config_module}") @@ -144,5 +145,6 @@ BACKUP_AUTHORIZED_KEYS = module.BACKUP_AUTHORIZED_KEYS DATABASES = module.DATABASES SECRET_KEY = module.SECRET_KEY MARKDOWN_PAGE_DIR = module.MARKDOWN_PAGE_DIR +BACKUP_MANAGE_PY = module.BACKUP_MANAGE_PY DEBUG = getattr(module, "DEBUG", False) diff --git a/community_backup/webui/deployments.py b/community_backup/webui/deployments.py index 527dda3..1b73a2e 100644 --- a/community_backup/webui/deployments.py +++ b/community_backup/webui/deployments.py @@ -34,8 +34,10 @@ def sync_repos(dry_run=False): commands = [] for key, repositories in repos_by_key.items(): repo_paths = [f"--restrict-to-repository {repo.path}" for repo in repositories] + quota = max(repo.quota for repo in repositories) + ids = [str(repo.id) for repo in repositories] commands.append( - f"""command="cd {str(settings.BACKUP_BORG_DIR)}; borg serve {" ".join(repo_paths)} --storage-quota=500G",restrict {key}""" + f"""command="cd {str(settings.BACKUP_BORG_DIR)}; borg serve {" ".join(repo_paths)} --storage-quota={quota}G;{settings.BACKUP_MANAGE_PY} update_used_quota --repository-id {" ".join(ids)}",restrict {key}""" ) if not dry_run: diff --git a/community_backup/webui/management/commands/update_used_quota.py b/community_backup/webui/management/commands/update_used_quota.py new file mode 100644 index 0000000..b19e661 --- /dev/null +++ b/community_backup/webui/management/commands/update_used_quota.py @@ -0,0 +1,28 @@ +from django.core.management.base import BaseCommand +from ...models import BorgRepository + + +class Command(BaseCommand): + help = "Update the used quota values" + + def add_arguments(self, parser): + parser.add_argument("--user-id", type=int) + parser.add_argument("--repository-id", type=int, nargs="+") + + def handle(self, *args, **options): + print(options) + + qs = BorgRepository.objects.all() + + if options.get("user_id"): + qs = qs.filter(user__pk=options.get("user_id")) + + if options.get("repository_id"): + qs = qs.filter(pk__in=options.get("repository_id")) + + print(qs) + + for repo in qs: + repo.refresh_quota() + + BorgRepository.objects.bulk_update(qs, ["used_quota"]) diff --git a/community_backup/webui/migrations/0006_borgrepository_quota_borgrepository_used_quota.py b/community_backup/webui/migrations/0006_borgrepository_quota_borgrepository_used_quota.py new file mode 100644 index 0000000..83c9faa --- /dev/null +++ b/community_backup/webui/migrations/0006_borgrepository_quota_borgrepository_used_quota.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.3 on 2026-04-20 15:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webui", "0005_alter_borgrepository_key"), + ] + + operations = [ + migrations.AddField( + model_name="borgrepository", + name="quota", + field=models.IntegerField(default=500), + ), + migrations.AddField( + model_name="borgrepository", + name="used_quota", + field=models.IntegerField(default=0), + ), + ] diff --git a/community_backup/webui/migrations/0007_alter_borgrepository_used_quota.py b/community_backup/webui/migrations/0007_alter_borgrepository_used_quota.py new file mode 100644 index 0000000..0c8cca9 --- /dev/null +++ b/community_backup/webui/migrations/0007_alter_borgrepository_used_quota.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-04-20 17:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("webui", "0006_borgrepository_quota_borgrepository_used_quota"), + ] + + operations = [ + migrations.AlterField( + model_name="borgrepository", + name="used_quota", + field=models.IntegerField(default=-1), + ), + ] diff --git a/community_backup/webui/models.py b/community_backup/webui/models.py index d04785a..4acc2f4 100644 --- a/community_backup/webui/models.py +++ b/community_backup/webui/models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.core.validators import RegexValidator from django.conf import settings from pathlib import Path +import msgpack from .tasks import update_user @@ -28,6 +29,9 @@ class BorgRepository(models.Model): ) user = models.ForeignKey(User, on_delete=models.CASCADE) + quota = models.IntegerField(default=500) + used_quota = models.IntegerField(default=-1) + class Meta: constraints = [ models.UniqueConstraint( @@ -95,6 +99,21 @@ class BorgRepository(models.Model): return output + def refresh_quota(self): + hints_files = self.path.glob("hints.*") + + try: + hint = next(hints_files) + data = hint.open(mode="rb").read() + + unpacked = msgpack.unpackb(data, strict_map_key=False, raw=True) + quota_used = unpacked[b"storage_quota_use"] + + self.used_quota = int(quota_used / 10**9) + except StopIteration: + # No hints file found, therefore the repo is probably not initialized + self.used_quota = -1 + class Voucher(models.Model): used = models.BooleanField(default=False) diff --git a/community_backup/webui/templates/borg_list.html b/community_backup/webui/templates/borg_list.html index cd508d4..f2e550b 100644 --- a/community_backup/webui/templates/borg_list.html +++ b/community_backup/webui/templates/borg_list.html @@ -18,6 +18,7 @@ Name Key Repo-URL + Quota (used/total) @@ -25,7 +26,8 @@ {{ repo.name }} {{ repo.truncated_key }} - {{ repo.repo_url}} + {{ repo.repo_url }} + {% if repo.used_quota < 0 %}not initialized{% else %}{{ repo.used_quota }}/{{ repo.quota }} GB{% endif %} Edit Delete diff --git a/pyproject.toml b/pyproject.toml index 9e552ec..203799e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,4 +10,5 @@ dependencies = [ "django-crispy-forms>=2.6", "gunicorn>=25.3.0", "markdown2>=2.5.5", + "msgpack>=1.1.2", ] From 4bfab4d6993f9f34a7ce0cfc3e9e4e96a40bcf3c Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Mon, 20 Apr 2026 19:04:09 +0200 Subject: [PATCH 06/16] Release 0.8 --- community_backup/community_backup/settings.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/community_backup/community_backup/settings.py b/community_backup/community_backup/settings.py index 0a7f6d1..c766e6a 100644 --- a/community_backup/community_backup/settings.py +++ b/community_backup/community_backup/settings.py @@ -115,7 +115,7 @@ TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBacken STATIC_ROOT = BASE_DIR.parent.parent / "static" -RELEASE_VERSION = "0.7" +RELEASE_VERSION = "0.8" # Import settings from configuration.py try: diff --git a/pyproject.toml b/pyproject.toml index 203799e..8ffcd76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "community_backup" -version = "0.7" +version = "0.8" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" From fb76fffdba5a809c0cd38f1aa095115887829c10 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Sat, 25 Apr 2026 13:54:13 +0200 Subject: [PATCH 07/16] moved the repo URL to the new URL format --- community_backup/webui/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community_backup/webui/models.py b/community_backup/webui/models.py index 4acc2f4..36e338f 100644 --- a/community_backup/webui/models.py +++ b/community_backup/webui/models.py @@ -48,7 +48,7 @@ class BorgRepository(models.Model): @property def repo_url(self) -> str: - return f"{settings.BACKUP_USER}@{settings.BACKUP_REPO_HOST}:{self.user.pk}/{self.pk}" + return f"ssh://{settings.BACKUP_USER}@{settings.BACKUP_REPO_HOST}/./{self.user.pk}/{self.pk}" def save(self, *args, **kwargs): From 68533d9983584cbd98276fef86b8917bb129686c Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Sat, 25 Apr 2026 17:00:37 +0200 Subject: [PATCH 08/16] Release 0.9 --- community_backup/community_backup/settings.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/community_backup/community_backup/settings.py b/community_backup/community_backup/settings.py index c766e6a..004da17 100644 --- a/community_backup/community_backup/settings.py +++ b/community_backup/community_backup/settings.py @@ -115,7 +115,7 @@ TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBacken STATIC_ROOT = BASE_DIR.parent.parent / "static" -RELEASE_VERSION = "0.8" +RELEASE_VERSION = "0.9" # Import settings from configuration.py try: diff --git a/pyproject.toml b/pyproject.toml index 8ffcd76..eb5c918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "community_backup" -version = "0.8" +version = "0.9" description = "Add your description here" readme = "README.md" requires-python = ">=3.13" From 72fee741e66501c2f1f69afd8f37ef421afbc1d1 Mon Sep 17 00:00:00 2001 From: Johannes Erwerle Date: Wed, 6 May 2026 10:23:17 +0200 Subject: [PATCH 09/16] added option to add links to the footer --- README.md | 8 ++++++++ .../community_backup/context_processors.py | 5 +++++ community_backup/community_backup/settings.py | 2 ++ community_backup/webui/templates/base.html | 11 +++++++++++ 4 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 8209c94..6d5a710 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,11 @@ Django debug setting. ### BACKUP_MANAGE_PY `BACKUP_MANAGE_PY` is the command to run the `manage.py` file, including e.g. a Python interpreter, venv, etc. This must be a `pathlib.Path` + +### ADDITIONAL_FOOTER_NAV_ITEMS + +`ADDITIONAL_FOOTER_NAV_ITEMS` allows you to add additional links to the footer. This is a `list[str]`. Each item gets rendered as is into a `