initial commit

This commit is contained in:
Johannes Erwerle 2026-04-04 15:07:37 +02:00
commit 164fa61ad2
40 changed files with 1263 additions and 0 deletions

209
.gitignore vendored Normal file
View file

@ -0,0 +1,209 @@
# Created by https://www.toptal.com/developers/gitignore/api/vim,venv,python
# Edit at https://www.toptal.com/developers/gitignore?templates=vim,venv,python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### venv ###
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
pip-selfcheck.json
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
*~
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
# End of https://www.toptal.com/developers/gitignore/api/vim,venv,python

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Community Backup
A website to manage backup repositories.
## Installation
TODO

View file

@ -0,0 +1,16 @@
"""
ASGI config for community_backup project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
application = get_asgi_application()

View file

@ -0,0 +1,2 @@
# enter your overrides here
print("foo")

View file

@ -0,0 +1,130 @@
"""
Django settings for community_backup project.
Generated by 'django-admin startproject' using Django 6.0.3.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-_53=1gp5m=j+ienpcguini#xczk^d+&jx*#@z+2837+_-0=+$z"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [
"*",
]
# Application definition
INSTALLED_APPS = [
"webui",
"crispy_forms",
"crispy_bootstrap5",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "community_backup.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "community_backup.wsgi.application"
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = "static/"
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
from .configuration import *

View file

@ -0,0 +1,25 @@
"""
URL configuration for community_backup project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("", include("webui.urls")),
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls"))
]

View file

@ -0,0 +1,16 @@
"""
WSGI config for community_backup project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
application = get_wsgi_application()

23
community_backup/manage.py Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "community_backup.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

View file

@ -0,0 +1,7 @@
from django.contrib import admin
from .models import BorgRepository, Voucher
# Register your models here.
admin.site.register(BorgRepository)
admin.site.register(Voucher)

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class WebuiConfig(AppConfig):
name = "webui"

View file

@ -0,0 +1,157 @@
from pathlib import Path
from subprocess import run
from collections import defaultdict
from typing import Any
from logging import getLogger
import shutil
from django.contrib.auth.models import User
from .models import BorgRepository
logger = getLogger("deployments")
# sync users
def get_users(user_prefix: str = "u-") -> dict[str, dict[str, Any]]:
passwd = Path("/etc/passwd").read_text()
output = dict()
for line in passwd.splitlines():
name, _, _, _, _, homedir, _ = line.split(":")
if not name.startswith(user_prefix):
continue
output[name] = {"homedir": Path(homedir)}
return output
def cleanup_users(
usernames: set[str],
system_users: dict[str, dict[str, Any]],
dry_run=False,
):
"""Cleans up all unused user accounts and directories on the system.
usernames is the set of users that should exist.
system_users contains all the users that exist (with the correct prefix)"""
base_dir = Path("/home/")
# cleanup users
for user in system_users.keys():
if user not in usernames:
logger.info("Deleting user '%(user)s'.")
print(f"Deleting user '{user}'.")
if not dry_run:
run(["userdel", user])
pass
# cleanup directories
for dir in base_dir.iterdir():
if dir.is_dir() and dir.name.startswith("u-") and dir.name not in usernames:
logger.info("Deleting home directory '%(dir)s'")
print(f"Deleting home directory '{dir}'")
if not dry_run:
shutil.rmtree(dir)
pass
def create_users(
usernames: set[str],
system_users: dict[str, dict[str, Any]],
dry_run=False,
):
for username in usernames:
if username not in system_users.keys():
logger.info("Adding user '%(username)s'")
print(f"Adding user '{username}'")
if not dry_run:
add_user(username)
def add_user(username: str):
users = get_users()
assert username not in users.keys(), "User already exists"
run(["useradd", "--no-user-group", "--create-home", username], check=True)
def sync_users(dry_run=False):
# gather data
users = User.objects.all()
usernames = {f"u-{user.pk}" for user in users}
system_users = get_users()
# clean up old users
cleanup_users(usernames=usernames, system_users=system_users, dry_run=dry_run)
# create the new users
create_users(usernames=usernames, system_users=system_users, dry_run=dry_run)
for user in users:
username = f"u-{user.pk}"
sync_repos(
username=username,
user=user,
system_user=system_users[username],
dry_run=dry_run,
)
# sync authorized_keys
def sync_repos(username: str, user: User, system_user: dict[str, Any], dry_run=False):
repos = BorgRepository.objects.filter(user=user)
repos_by_key = defaultdict(list)
for repo in repos:
repos_by_key[repo.key].append(repo)
homedir = system_user["homedir"]
# create .ssh directory
ssh_dir = homedir / ".ssh"
ssh_dir.mkdir(
mode=0o750,
exist_ok=True,
parents=True,
)
# create authorized_keys file
authorized_keys = ssh_dir / "authorized_keys"
commands = []
for key, repos in repos_by_key.items():
repo_paths = [
f"--restrict-to-repository {str(system_user['homedir'] / repo.name)}"
for repo in repos
]
commands.append(
f"""command="borg serve {" ".join(repo_paths)} --quota=500G",restrict ssh-rsa {key}"""
)
print("\n".join(commands))
authorized_keys.write_text("\n".join(commands) + "\n")
# remove repositories that do no longer exist
repo_names = {repo.name for repo in repos}
print(repo_names)
for dir in homedir.iterdir():
print(dir.name)
if dir.is_dir() and not dir.name.startswith("."):
if dir.name not in repo_names:
print(f"removing unused repo '{dir}'")
shutil.rmtree(dir)
# create the repositories
for repo in repos:
path = system_user["homedir"] / repo.name
path.mkdir(mode=0o750, exist_ok=True, parents=True)
shutil.chown(path, user=username)

View file

@ -0,0 +1,40 @@
from .models import BorgRepository
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.core.exceptions import ValidationError
from .models import Voucher
class BorgRepositoryForm(forms.ModelForm):
class Meta:
fields = ["name", "key", "user"]
model = BorgRepository
widgets = {"user": forms.HiddenInput()}
class RegisterUserForm(UserCreationForm):
email = forms.EmailField()
voucher = forms.CharField(help_text="You registration voucher.")
def clean_voucher(self):
obj = Voucher.objects.filter(code=self.cleaned_data["voucher"], used=False)
if not obj.exists():
raise ValidationError(
"Voucher code '%(code)s' is invalid",
code="invalid",
params={"code": self.cleaned_data["voucher"]},
)
return obj.first()
def save(self, commit=True):
voucher = self.cleaned_data["voucher"]
voucher.used = True
voucher.save()
super().save(commit)
class Meta(UserCreationForm.Meta):
fields = ("username", "email")

View file

@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand, CommandError
from ...models import BorgRepository
from ...deployments import sync_users
class Command(BaseCommand):
help = "Synchronized users on the host with the database."
def handle(self, *args, **options):
repos = BorgRepository.objects.all()
for repo in repos:
self.stdout.write(f"{repo}")
self.stdout.write(self.style.SUCCESS("This is a test"))
sync_users(dry_run=False)

View file

@ -0,0 +1,40 @@
# Generated by Django 6.0.3 on 2026-03-23 06:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="BorgRepository",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("key", models.TextField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0.3 on 2026-03-28 06:56
import django.core.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("webui", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
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.",
)
]
),
),
migrations.AddConstraint(
model_name="borgrepository",
constraint=models.UniqueConstraint(
fields=("name", "user"), name="BorgRepository_name_user_unique"
),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 6.0.3 on 2026-03-28 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("webui", "0002_alter_borgrepository_key_and_more"),
]
operations = [
migrations.CreateModel(
name="Voucher",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("used", models.BooleanField(default=False)),
("code", models.CharField(max_length=100, unique=True)),
],
),
]

View file

@ -0,0 +1,59 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import RegexValidator
# Create your models here.
class BorgRepository(models.Model):
name = models.CharField(
max_length=100,
validators=[
RegexValidator(
r"[a-zA-Z0-9\-_]+", message="Only a-z, A-Z, 0-9, - and _ are allowed."
)
],
)
key = models.TextField(
validators=[
RegexValidator(
r"(ssh\-rsa|ecdsa\-sha2\-nistp256|ssh\-ed25519) ([a-zA-Z0-9\+/]+) (\S*)",
message="not a valid SSH public key.",
),
]
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("name", "user"), name="BorgRepository_name_user_unique"
)
]
def __str__(self):
return f"BorgRepository '{self.user.username}' - '{self.name}' ({self.pk})"
@property
def username(self) -> str:
"""Returns the username of the linux user account for this user."""
return f"u-{self.pk}"
def save(self):
from .tasks import update_user
update_user.enqueue(user_pk=self.user.pk)
super().save()
def delete(self):
from .tasks import update_user
super().delete()
update_user.enqueue(user_pk=self.user.pk)
class Voucher(models.Model):
used = models.BooleanField(default=False)
code = models.CharField(max_length=100, unique=True)
def __str__(self):
return f"Voucher '{self.code}'"

View file

@ -0,0 +1,12 @@
from django.tasks import task
from .deployments import sync_repos, get_users
from django.contrib.auth.models import User
@task
def update_user(user_pk):
user = User.objects.get(pk=user_pk)
username = f"u-{user.pk}"
system_user = get_users()[username]
sync_repos(username=username, user=user, system_user=system_user)

View file

@ -0,0 +1,6 @@
# About Community Backup
Community Backup is a service that provides backup space free of charge.
* This is
* a list

View file

@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Community Backup</title>
</head>
<body style="display: flex; flex-direction: column; height: 100vh">
<header class="bg-body-tertiary">
<div class="container">
<nav class="navbar navbar-expand-lg">
<div class="container-fluid">
<a class="navbar-brand" href="/" >Community Backup</a>
<div class="navbar-nav">
{% if request.user.is_authenticated %}
<a class="nav-link" href="{% url 'borg_list' %}">Borg Repositories</a>
<div class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ user.username }}
</a>
<ul class="dropdown-menu">
<li><form method=post action="{% url 'logout' %}?next={% url 'landing' %}">
{% csrf_token %}
<button type="submit" class="btn dropdown-item">Logout</button>
</form></li>
<li><a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a></li>
{% if user.is_staff %}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
</ul>
</div>
{% else %}
<a class="btn nav-item" type="button" href="{% url 'login' %}?next={% url 'landing' %}">Login</a>
<a class="btn nav-item" type="button" href="{% url 'register' %}">Register</a>
{% endif %}
</div>
</nav>
</div>
</header>
<main class="container" style="flex: 1 1 auto">
{% block content %}
{% endblock %}
</main>
<footer class="bg-body-tertiary">
<div class="container">
<div class="row">
<ul>
<li>
<a href="{% url "about" %}">About</a>
</li>
<li>
<a href="#">Imprint</a>
</li>
</ul>
</div>
</div>
</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>
</body>
</html>

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<form action="{% url borg_edit %}">
{{ form }}
</form>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="d-flex">
<div class="p-2 ms-auto">
<a class="btn btn-primary" href="{% url 'borg_add' %}" role="button">Add Repository</a>
</div>
</div>
<table class="table table-striped table-hover">
<thead>
<th>Name</th>
<th>Key</th>
<th></th>
</thead>
<tbody>
{% for repo in object_list %}
<tr>
<td>{{ repo.name }}</td>
<td>{{ repo.key }}</td>
<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>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<row class="text-center p-2">
<h1>Welcome to Community Backup!</h1>
</row>
{% endblock %}

View file

@ -0,0 +1 @@
Hello at the login template

View file

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<row class="p-2">
{{ markdown_content|safe }}
</row>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% if form.errors %}
{% for error in form.errors %}
<p>{{ error }}</p>
{% endfor %}
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form method="post" action="{% url 'register' %}">
{% csrf_token %}
{{ form|crispy }}
<input class="btn btn-primary" type="submit" value="Register">
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
{% if next %}
{% if user.is_authenticated %}
<p>Your account doesn't have access to this page. To proceed,
please login with an account that has access.</p>
{% else %}
<p>Please login to see this page.</p>
{% endif %}
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form|crispy }}
<input class="btn btn-primary" type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">{% csrf_token %}
<p>Are you sure you want to delete "{{ object.name }}"?</p>
{{ form }}
<input class="btn btn-danger" type="submit" value="Confirm">
</form>
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form|crispy }}
{% if form.initial %}
<input class="btn btn-primary" type="submit" value="Save">
{% else %}
<input class="btn btn-primary" type="submit" value="Add">
{% endif %}
</form>
{% endblock %}

View file

@ -0,0 +1,12 @@
from django import template
from django.template.defaultfilters import stringfilter
import markdown2 as md
register = template.Library()
@register.filter()
@stringfilter
def markdown(value):
return md.markdown(value, extensions=["markdown.extensions.fenced_code"])

View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View file

@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.LandingView.as_view(), name="landing"),
path("borg/", views.BorgView.as_view(), name="borg_list"),
path("borg/add/", views.BorgRepositoryCreateView.as_view(), name="borg_add"),
path(
"borg/update/<int:pk>/",
views.BorgRepositoryUpdateView.as_view(),
name="borg_update",
),
path(
"borg/delete/<int:pk>/",
views.BorgRepositoryDeleteView.as_view(),
name="borg_delete",
),
path("register/", views.RegisterUserView.as_view(), name="register"),
path("about/", views.AboutView.as_view(), name="about"),
]

View file

@ -0,0 +1,93 @@
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
from django.views.generic import ListView, FormView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.contrib.auth.models import User
from django.contrib.auth.forms import BaseUserCreationForm
from django.contrib.auth.mixins import UserPassesTestMixin, LoginRequiredMixin
from django.template.loader import get_template
from django.core.exceptions import ValidationError
import markdown2
from .models import BorgRepository, Voucher
from .forms import BorgRepositoryForm, RegisterUserForm
class UserOwnsRepositoryMixin(UserPassesTestMixin):
def test_func(self):
repo = get_object_or_404(BorgRepository, pk=self.kwargs["pk"])
return repo.user == self.request.user
class LoginView(TemplateView):
template_name = "login.html"
class LandingView(TemplateView):
template_name = "landing.html"
class MarkdownView(TemplateView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
source = get_template(self.md).template.source
context["markdown_content"] = markdown2.markdown(source)
return context
class AboutView(MarkdownView):
template_name = "markdown.html"
md = "about.md"
class BorgView(LoginRequiredMixin, ListView):
template_name = "borg_list.html"
content_object_name = "borg_repositories"
def get_queryset(self):
data = BorgRepository.objects.filter(user=self.request.user)
return data
class RegisterUserView(FormView):
template_name = "register.html"
form_class = RegisterUserForm
success_url = "/"
def form_valid(self, form):
form.save()
return super().form_valid(form)
class BorgRepositoryCreateView(LoginRequiredMixin, CreateView):
model = BorgRepository
success_url = "/borg/"
form_class = BorgRepositoryForm
def get_initial(self):
return {"user": self.request.user}
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class BorgRepositoryDeleteView(LoginRequiredMixin, UserOwnsRepositoryMixin, DeleteView):
model = BorgRepository
success_url = "/borg/"
class BorgRepositoryUpdateView(LoginRequiredMixin, UserOwnsRepositoryMixin, UpdateView):
model = BorgRepository
success_url = "/borg/"
form_class = BorgRepositoryForm
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)

12
pyproject.toml Normal file
View file

@ -0,0 +1,12 @@
[project]
name = "community_backup"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"crispy-bootstrap5>=2026.3",
"django>=6.0.3",
"django-crispy-forms>=2.6",
"markdown2>=2.5.5",
]

97
uv.lock generated Normal file
View file

@ -0,0 +1,97 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "community-backup"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "crispy-bootstrap5" },
{ name = "django" },
{ name = "django-crispy-forms" },
{ name = "markdown2" },
]
[package.metadata]
requires-dist = [
{ name = "crispy-bootstrap5", specifier = ">=2026.3" },
{ name = "django", specifier = ">=6.0.3" },
{ name = "django-crispy-forms", specifier = ">=2.6" },
{ name = "markdown2", specifier = ">=2.5.5" },
]
[[package]]
name = "crispy-bootstrap5"
version = "2026.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-crispy-forms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/e8/05e1170f6b8fbfe4098392bdca813c2094659853a438919234d4663009b1/crispy_bootstrap5-2026.3.tar.gz", hash = "sha256:e7f5adb36acfbb456444c46e82c436931c796c539e9c620be4fa9dc9c9d6679c", size = 23452, upload-time = "2026-03-01T10:08:00.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/2d/93f78072f203aa28d961add6a130b929b47f7aa3fef6905898b4bb9a637d/crispy_bootstrap5-2026.3-py3-none-any.whl", hash = "sha256:e0fff85c0503e9aed610a0ee31368e2191d340657f813669491c288c1c2e2dfa", size = 24770, upload-time = "2026-03-01T10:07:59.058Z" },
]
[[package]]
name = "django"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" },
]
[[package]]
name = "django-crispy-forms"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/73/42/c2cfb672493730b963ef377b103e29871c56348a215d0ae8cf362fe8ab1e/django_crispy_forms-2.6.tar.gz", hash = "sha256:4921a1087c6cd4f9fa3c139654c1de1c1c385f8bd6729aaee530bc0121ab4b93", size = 1097838, upload-time = "2026-03-01T09:03:37.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/e3/4c5915a732d6ab54da8871400852b67529518eedfb6b78ecf10bbccfcabb/django_crispy_forms-2.6-py3-none-any.whl", hash = "sha256:8ee0ae28b6b0ac41ff48a65944480c049fe8d1b0047086874fd7efabf4ec1374", size = 31479, upload-time = "2026-03-01T09:03:36.048Z" },
]
[[package]]
name = "markdown2"
version = "2.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]