Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0b69596c8 | |||
| 71e72fb98b | |||
| a942a0b8c6 | |||
| 95fde8e58f | |||
| ea9346d25d | |||
| d0b7158423 | |||
| 528d7b9e4b | |||
| 72fee741e6 | |||
| 68533d9983 | |||
| fb76fffdba | |||
| 4bfab4d699 | |||
| 3a4f5a0394 | |||
| 755e313c61 | |||
| 3e84c036f7 | |||
| 02fad76482 | |||
| 8b46b747de | |||
| 942b658160 | |||
| 2f0db972de | |||
| 79bc9a92be | |||
| 47a1dbc4cb | |||
| 6f75cfaf9a | |||
| 11f17c5624 |
22 changed files with 430 additions and 27 deletions
56
README.md
56
README.md
|
|
@ -78,6 +78,24 @@ Create a superuser account
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Repository Quota Updates
|
||||||
|
|
||||||
|
The repository quotas in the UI are updated after each backup.
|
||||||
|
To update the quota during an operation the `update_used_quota` management command can be executed on a regular basis.
|
||||||
|
|
||||||
|
There are example configs for systemd-timers in `contrib/communitybackup-quota-update.service` and `contrib/communitybackup-quota-update.timer`. Adjust the paths and the timers to your needs.
|
||||||
|
|
||||||
|
|
||||||
|
## Custom pages
|
||||||
|
|
||||||
|
Some pages are specific to your installation. E.g. an imprint.
|
||||||
|
|
||||||
|
Those files are markdown files that are rendered as HTML. You have to supply them and put them into the `MARKDOWN_PAGE_DIR`.
|
||||||
|
|
||||||
|
The following files are expected:
|
||||||
|
|
||||||
|
* `imprint.md` with your imprint information.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
### BACKUP_USER
|
### BACKUP_USER
|
||||||
|
|
@ -120,3 +138,41 @@ The secret key used by django for session cookies and other things.
|
||||||
### DEBUG
|
### DEBUG
|
||||||
|
|
||||||
Django debug setting.
|
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`
|
||||||
|
|
||||||
|
### 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 `<li class=nav-item>`. So usually you want to continue the proper Bootstrap styling in the lst. E.g.:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADDITIONAL_FOOTER_NAV_ITEMS = ["""<a class="nav-link" href="https://example.com/>Example Link</a>""",]
|
||||||
|
```
|
||||||
|
|
||||||
|
### BORG_SERVER_PUBKEYS
|
||||||
|
|
||||||
|
A list of the SSH public keys and their hashes to verify the server a User is connecting to.
|
||||||
|
This is a list of tuples, containing the key and it's hash.
|
||||||
|
|
||||||
|
```python
|
||||||
|
BORG_SERVER_PUBKEYS = [
|
||||||
|
(
|
||||||
|
"ecdsa-sha2-nistp256 AAAAASDJIASKJDASD root@example.com",
|
||||||
|
"256 SHA256:sTbOK9NvP1uUEixgUT8KUiYrY8J/DbK+jR39lwcT8Zw root@example.com (ECDSA)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssh-ed25519 AAAAasdiwdkjasdijwklajsdijasd root@example.com",
|
||||||
|
"256 SHA256:hPbWwRxNr1mFHZKYjcysnay1cQGQsOmDBvkA3Pzo4YY root@example.com (ED25519)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ssh-rsa AAAABasdlkjasdiualksjd root@example.com",
|
||||||
|
"3072 SHA256:deuPTR8Hcc1LP7DHqAp91EINdLBQoco2IeMldIahamQ root@example.com (RSA)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
|
||||||
19
community_backup/community_backup/context_processors.py
Normal file
19
community_backup/community_backup/context_processors.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def release_version(_request):
|
||||||
|
return {
|
||||||
|
"RELEASE_VERSION": settings.RELEASE_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def additional_footer_nav_items(_request):
|
||||||
|
return {
|
||||||
|
"ADDITIONAL_FOOTER_NAV_ITEMS": settings.ADDITIONAL_FOOTER_NAV_ITEMS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def borg_server_pubkeys(_request):
|
||||||
|
return {
|
||||||
|
"BORG_SERVER_PUBKEYS": settings.BORG_SERVER_PUBKEYS,
|
||||||
|
}
|
||||||
|
|
@ -19,3 +19,21 @@ SECRET_KEY = "change me!"
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
# DEBUG = True
|
# DEBUG = True
|
||||||
|
|
||||||
|
MARKDOWN_PAGE_DIR = Path("./custom_md/")
|
||||||
|
BACKUP_MANAGE_PY = Path("/path/to/venv/bin/python /path/to/community_backup/manage.py")
|
||||||
|
|
||||||
|
# BORG_SERVER_PUBKEYS = [
|
||||||
|
# (
|
||||||
|
# "ecdsa-sha2-nistp256 AAAAASDJIASKJDASD root@example.com",
|
||||||
|
# "256 SHA256:sTbOK9NvP1uUEixgUT8KUiYrY8J/DbK+jR39lwcT8Zw root@example.com (ECDSA)",
|
||||||
|
# ),
|
||||||
|
# (
|
||||||
|
# "ssh-ed25519 AAAAasdiwdkjasdijwklajsdijasd root@example.com",
|
||||||
|
# "256 SHA256:hPbWwRxNr1mFHZKYjcysnay1cQGQsOmDBvkA3Pzo4YY root@example.com (ED25519)",
|
||||||
|
# ),
|
||||||
|
# (
|
||||||
|
# "ssh-rsa AAAABasdlkjasdiualksjd root@example.com",
|
||||||
|
# "3072 SHA256:deuPTR8Hcc1LP7DHqAp91EINdLBQoco2IeMldIahamQ root@example.com (RSA)",
|
||||||
|
# ),
|
||||||
|
# ]
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ TEMPLATES = [
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"community_backup.context_processors.release_version",
|
||||||
|
"community_backup.context_processors.additional_footer_nav_items",
|
||||||
|
"community_backup.context_processors.borg_server_pubkeys",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -114,6 +117,8 @@ TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBacken
|
||||||
|
|
||||||
STATIC_ROOT = BASE_DIR.parent.parent / "static"
|
STATIC_ROOT = BASE_DIR.parent.parent / "static"
|
||||||
|
|
||||||
|
RELEASE_VERSION = "0.12"
|
||||||
|
|
||||||
# Import settings from configuration.py
|
# Import settings from configuration.py
|
||||||
try:
|
try:
|
||||||
config_module = "community_backup.configuration"
|
config_module = "community_backup.configuration"
|
||||||
|
|
@ -127,6 +132,8 @@ try:
|
||||||
assert module.BACKUP_AUTHORIZED_KEYS, (
|
assert module.BACKUP_AUTHORIZED_KEYS, (
|
||||||
"The BACKUP_AUTHORIZED_KEYS setting is required."
|
"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:
|
except ModuleNotFoundError:
|
||||||
print(f"could not find configuration file {config_module}")
|
print(f"could not find configuration file {config_module}")
|
||||||
|
|
@ -139,5 +146,9 @@ BACKUP_BORG_DIR = module.BACKUP_BORG_DIR
|
||||||
BACKUP_AUTHORIZED_KEYS = module.BACKUP_AUTHORIZED_KEYS
|
BACKUP_AUTHORIZED_KEYS = module.BACKUP_AUTHORIZED_KEYS
|
||||||
DATABASES = module.DATABASES
|
DATABASES = module.DATABASES
|
||||||
SECRET_KEY = module.SECRET_KEY
|
SECRET_KEY = module.SECRET_KEY
|
||||||
|
MARKDOWN_PAGE_DIR = module.MARKDOWN_PAGE_DIR
|
||||||
|
BACKUP_MANAGE_PY = module.BACKUP_MANAGE_PY
|
||||||
|
|
||||||
DEBUG = getattr(module, "DEBUG", False)
|
DEBUG = getattr(module, "DEBUG", False)
|
||||||
|
ADDITIONAL_FOOTER_NAV_ITEMS = getattr(module, "ADDITIONAL_FOOTER_NAV_ITEMS", list())
|
||||||
|
BORG_SERVER_PUBKEYS = getattr(module, "BORG_SERVER_PUBKEYS", list())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Update the used quota
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=borg
|
||||||
|
Group=borg
|
||||||
|
WorkingDirectory=/opt/community_backup/community-backup/community_backup/
|
||||||
|
ExecStart=/opt/community_backup/venv/bin/python manage.py update_used_quota
|
||||||
|
PrivateTmp=true
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Update the used quota on a regular basis
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:0/5:00
|
||||||
|
RandomizedDelaySec=120
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
0
community_backup/tests/__init__.py
Normal file
0
community_backup/tests/__init__.py
Normal file
54
community_backup/tests/test_models.py
Normal file
54
community_backup/tests/test_models.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -8,8 +8,6 @@ from django.conf import settings
|
||||||
|
|
||||||
logger = getLogger("deployments")
|
logger = getLogger("deployments")
|
||||||
|
|
||||||
# sync users
|
|
||||||
|
|
||||||
|
|
||||||
def sync_repos(dry_run=False):
|
def sync_repos(dry_run=False):
|
||||||
"""Synchronize the repos"""
|
"""Synchronize the repos"""
|
||||||
|
|
@ -19,7 +17,7 @@ def sync_repos(dry_run=False):
|
||||||
|
|
||||||
repos_by_key = defaultdict(list)
|
repos_by_key = defaultdict(list)
|
||||||
for repo in repos:
|
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
|
# create .ssh directory
|
||||||
ssh_dir = settings.BACKUP_AUTHORIZED_KEYS.parent
|
ssh_dir = settings.BACKUP_AUTHORIZED_KEYS.parent
|
||||||
|
|
@ -36,8 +34,10 @@ def sync_repos(dry_run=False):
|
||||||
commands = []
|
commands = []
|
||||||
for key, repositories in repos_by_key.items():
|
for key, repositories in repos_by_key.items():
|
||||||
repo_paths = [f"--restrict-to-repository {repo.path}" for repo in repositories]
|
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(
|
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:
|
if not dry_run:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class BorgRepositoryForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ["name", "key", "user"]
|
fields = ["name", "key", "user"]
|
||||||
model = BorgRepository
|
model = BorgRepository
|
||||||
widgets = {"user": forms.HiddenInput()}
|
widgets = {"user": forms.HiddenInput(), "key": forms.TextInput()}
|
||||||
|
|
||||||
|
|
||||||
class RegisterUserForm(UserCreationForm):
|
class RegisterUserForm(UserCreationForm):
|
||||||
|
|
|
||||||
39
community_backup/webui/management/commands/add_vouchers.py
Normal file
39
community_backup/webui/management/commands/add_vouchers.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from random import choices
|
||||||
|
from ...models import Voucher
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "bulk-add vouchers with a given prefix and generate a latex output to render them"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("prefix", type=str)
|
||||||
|
parser.add_argument("amount", type=int)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
unabmigous_characters = "abcdefghijkmnopqrstuvwxyzACDEFHJKLMNPQRTUVWXY1234679"
|
||||||
|
|
||||||
|
vouchers = set()
|
||||||
|
while len(vouchers) < options["amount"]:
|
||||||
|
random_part = "".join(choices(unabmigous_characters, k=5))
|
||||||
|
vouchers.add(f"{options['prefix']}-{random_part}")
|
||||||
|
|
||||||
|
Voucher.objects.bulk_create(Voucher(code=v) for v in vouchers)
|
||||||
|
|
||||||
|
header = r"""\begin{document}
|
||||||
|
\begin{center}
|
||||||
|
"""
|
||||||
|
output = header
|
||||||
|
even = False
|
||||||
|
for v in vouchers:
|
||||||
|
output += f"\\voucher{{{v}}}"
|
||||||
|
if even:
|
||||||
|
output += "\\newline\n"
|
||||||
|
else:
|
||||||
|
output += "\n"
|
||||||
|
even = not even
|
||||||
|
|
||||||
|
output += r"""\end{center}
|
||||||
|
\end{document}"""
|
||||||
|
|
||||||
|
print(output)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
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):
|
||||||
|
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"))
|
||||||
|
|
||||||
|
for repo in qs:
|
||||||
|
repo.refresh_quota()
|
||||||
|
|
||||||
|
BorgRepository.objects.bulk_update(qs, ["used_quota"])
|
||||||
|
|
@ -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.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -3,6 +3,7 @@ from django.contrib.auth.models import User
|
||||||
from django.core.validators import RegexValidator
|
from django.core.validators import RegexValidator
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import msgpack
|
||||||
|
|
||||||
|
|
||||||
from .tasks import update_user
|
from .tasks import update_user
|
||||||
|
|
@ -21,13 +22,16 @@ class BorgRepository(models.Model):
|
||||||
key = models.TextField(
|
key = models.TextField(
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(
|
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.",
|
message="not a valid SSH public key.",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
quota = models.IntegerField(default=500)
|
||||||
|
used_quota = models.IntegerField(default=-1)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
|
|
@ -44,11 +48,11 @@ class BorgRepository(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def repo_url(self) -> str:
|
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):
|
def save(self, *args, **kwargs):
|
||||||
|
|
||||||
super().save()
|
super().save(*args, **kwargs)
|
||||||
update_user.enqueue()
|
update_user.enqueue()
|
||||||
|
|
||||||
def delete(self):
|
def delete(self):
|
||||||
|
|
@ -56,6 +60,60 @@ class BorgRepository(models.Model):
|
||||||
super().delete()
|
super().delete()
|
||||||
update_user.enqueue()
|
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
|
||||||
|
|
||||||
|
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):
|
class Voucher(models.Model):
|
||||||
used = models.BooleanField(default=False)
|
used = models.BooleanField(default=False)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
# About Community Backup
|
|
||||||
|
|
||||||
Community Backup is a service that provides backup space free of charge.
|
|
||||||
|
|
||||||
* This is
|
|
||||||
* a list
|
|
||||||
|
|
@ -46,12 +46,39 @@
|
||||||
<footer class="bg-body-tertiary">
|
<footer class="bg-body-tertiary">
|
||||||
<div class="container p-2">
|
<div class="container p-2">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'imprint' %}">Imprint</a>
|
<a class="nav-link" href="{% url 'imprint' %}">Imprint</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="https://git.srvspace.net/jo/community-backup">Source Code</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link disabled">Community Backup v{{ RELEASE_VERSION }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% if ADDITIONAL_FOOTER_NAV_ITEMS %}
|
||||||
|
<div class="col-3">
|
||||||
|
<ul class="nav flex-column">
|
||||||
|
{% for item in ADDITIONAL_FOOTER_NAV_ITEMS %}
|
||||||
|
<li class="nav-item">
|
||||||
|
{{item|safe}}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,16 @@
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Key</th>
|
<th>Key</th>
|
||||||
<th>Repo-URL</th>
|
<th>Repo-URL</th>
|
||||||
|
<th>Quota (used/total)</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for repo in object_list %}
|
{% for repo in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ repo.name }}</td>
|
<td>{{ repo.name }}</td>
|
||||||
<td><code>{{ repo.key | truncatechars:40 }}</code></td>
|
<td style="word-break: break-all"><code>{{ repo.truncated_key }}</code></td>
|
||||||
<td><code>{{ repo.repo_url}}</code></td>
|
<td style="word-break: break-all"><code>{{ repo.repo_url }}</code></td>
|
||||||
|
<td>{% if repo.used_quota < 0 %}not initialized{% else %}{{ repo.used_quota }}/{{ repo.quota }} GB{% endif %}</td>
|
||||||
<td><a href="{% url 'borg_update' pk=repo.pk %}" class="btn btn-warning btn-sm">Edit</a>
|
<td><a href="{% url 'borg_update' pk=repo.pk %}" class="btn btn-warning btn-sm">Edit</a>
|
||||||
<a href="{% url 'borg_delete' pk=repo.pk %}" class="btn btn-danger btn-sm">Delete</a></td>
|
<a href="{% url 'borg_delete' pk=repo.pk %}" class="btn btn-danger btn-sm">Delete</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -33,6 +35,21 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if BORG_SERVER_PUBKEYS %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4>Borg Server Public Keys</h4>
|
||||||
|
</div>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for key, fingerprint in BORG_SERVER_PUBKEYS %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span style="word-break: break-all"><code>{{ key }}</code></span><br>
|
||||||
|
<span style="word-break: break-all"><code>{{ fingerprint }}</code></span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
</row>
|
</row>
|
||||||
|
|
||||||
<row>
|
<row>
|
||||||
<p>This is a service offering space for backups free of charge. This is a hobby project. It comes with no garantuees for anything, especially availability of the service. This service is in a testing phase at the moment. It might be unavailalbe at times and features might change quickly.</p>
|
<p>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.</p>
|
||||||
<p>Currently this service offers only backups via <a href="https://www.borgbackup.org/">Borg Backup</a>.</p>
|
<p>Currently this service offers only backups via <a href="https://www.borgbackup.org/">Borg Backup</a>.</p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
</row>
|
</row>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
from django.views.generic import ListView, FormView
|
from django.views.generic import ListView, FormView
|
||||||
from django.views.generic.edit import CreateView, UpdateView, DeleteView
|
from django.views.generic.edit import CreateView, UpdateView, DeleteView
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.forms import BaseUserCreationForm
|
from django.contrib.auth.forms import BaseUserCreationForm
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
|
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
|
||||||
|
|
||||||
from django.template.loader import get_template
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
import markdown2
|
import markdown2
|
||||||
|
|
||||||
|
|
@ -36,7 +35,8 @@ class MarkdownView(TemplateView):
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
source = get_template(self.md).template.source
|
|
||||||
|
source = (settings.MARKDOWN_PAGE_DIR / self.md).read_text()
|
||||||
context["markdown_content"] = markdown2.markdown(source)
|
context["markdown_content"] = markdown2.markdown(source)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "community_backup"
|
name = "community_backup"
|
||||||
version = "0.1.0"
|
version = "0.12"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
@ -10,4 +10,5 @@ dependencies = [
|
||||||
"django-crispy-forms>=2.6",
|
"django-crispy-forms>=2.6",
|
||||||
"gunicorn>=25.3.0",
|
"gunicorn>=25.3.0",
|
||||||
"markdown2>=2.5.5",
|
"markdown2>=2.5.5",
|
||||||
|
"msgpack>=1.1.2",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue