This commit is contained in:
relikd
2023-10-02 23:39:20 +02:00
commit 8629b01da3
47 changed files with 1412 additions and 0 deletions

10
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
data/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

22
backend/Dockerfile Executable file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-alpine
EXPOSE 8099
# install base system
RUN apk add --no-cache gcc libc-dev linux-headers
RUN pip install --upgrade pip
RUN pip install uvicorn gunicorn
# install requirements
WORKDIR /django_project
COPY ./requirements.txt .
RUN pip install -r requirements.txt
# then scripts (likely wont change often)
ENV PATH="/scripts:/py/bin:$PATH"
COPY --chmod=700 ./scripts /scripts
# finally copy app (likely will invalidate cache)
COPY . .
CMD ["on-deploy.sh"]

0
backend/app/__init__.py Normal file
View File

41
backend/app/admin.py Normal file
View File

@@ -0,0 +1,41 @@
from django.contrib import admin
from django.contrib.auth.models import Group # , User
from django.utils.safestring import mark_safe
from .models import Organization, Member
admin.site.site_header = 'Ausweis-Verwaltung' # top-most title
admin.site.index_title = 'Ausweis' # title at root
admin.site.site_title = 'Ausweis-Verwaltung' # suffix to <title>
admin.site.unregister(Group)
# admin.site.unregister(User)
@admin.register(Organization)
class OrganizationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ['name']}
list_display = ('name', 'slug', 'member_count')
list_display_links = ('name', 'slug')
search_fields = ('name', 'slug')
@admin.display(description='Mitglieder')
def member_count(self, obj: 'Organization'):
return obj.members.count()
@admin.register(Member)
class MemberAdmin(admin.ModelAdmin):
readonly_fields = ('uuid', 'url_link_full')
list_display = (
'name', 'member_id', 'valid_since', 'valid_until', 'url_link_short')
search_fields = ('name', 'member_id')
@admin.display(description='URL')
def url_link_short(self, obj: 'Member'):
return mark_safe(f'<a target="blank" href="{obj.export_url}">Link</a>')
@admin.display(description='URL')
def url_link_full(self, obj: 'Member'):
return mark_safe('<a target="blank" href="{0}">{0}</a>'.format(
obj.export_url))

6
backend/app/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'app'

View File

@@ -0,0 +1,38 @@
from django.conf import settings
# from django.contrib.admin.widgets import AdminFileWidget
# from django.core.exceptions import ValidationError
from django.db import models
from django.forms import ValidationError
from django.forms.widgets import ClearableFileInput
# class ImageFileWidget(AdminFileWidget):
class ImageFileWidget(ClearableFileInput):
template_name = 'forms/img-file.html'
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context['MEDIA_URL'] = settings.MEDIA_URL
return context
class FileWithImagePreview(models.FileField):
def formfield(self, **kwargs):
# if 'widget' not in kwargs: # only if no other is set (admin UI)
kwargs['widget'] = ImageFileWidget
return super().formfield(**kwargs)
class ImageValidator:
help_text = 'Ideal: 250 x 320 px (JPEG oder PNG)'
@staticmethod
def validate(value: 'models.FieldFile') -> None:
# TODO: make configurable
if value.size > 512 * 1024:
raise ValidationError('Datei darf maximal 512 KB groß sein.')
content_type = getattr(value.file, 'content_type', None)
if content_type not in ['image/png', 'image/jpeg', None]:
raise ValidationError('Nur JPEG und PNG Bilder werden unterstützt')

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.5 on 2023-10-02 14:25
import app.form.file_with_img_preview
import app.utils
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Name')),
('slug', models.SlugField(unique=True, verbose_name='URL Slug')),
('exportBaseUrl', models.URLField(verbose_name='Export Base-URL')),
],
options={
'verbose_name': 'Organisation',
'verbose_name_plural': 'Organisationen',
},
),
migrations.CreateModel(
name='Member',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('secret', models.CharField(default=app.utils.random_secret, editable=False, max_length=20)),
('member_id', models.CharField(max_length=20, verbose_name='Mitglieder-Nr.')),
('name', models.CharField(max_length=100, verbose_name='Name')),
('valid_since', models.DateField(verbose_name='Mitglied seit')),
('valid_until', models.DateField(blank=True, null=True, verbose_name='Mitglied bis')),
('image', app.form.file_with_img_preview.FileWithImagePreview(blank=True, help_text='Ideal: 250 x 320 px (JPEG oder PNG)', null=True, upload_to=app.utils.overwrite_upload, validators=[app.form.file_with_img_preview.ImageValidator.validate], verbose_name='Bild')),
('additional', models.JSONField(blank=True, null=True, verbose_name='Zusätzliche Daten (JSON)')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='app.organization')),
],
options={
'verbose_name': 'Mitglied',
'verbose_name_plural': 'Mitglieder',
},
),
]

View File

View File

@@ -0,0 +1,2 @@
from .organization import Organization # noqa: F401
from .member import Member # noqa: F401

View File

@@ -0,0 +1,97 @@
from pathlib import PosixPath
from django.conf import settings
from django.db import models
from app.form.file_with_img_preview import FileWithImagePreview, ImageValidator
from app.utils import encrypt, overwrite_upload, random_secret
import os
import uuid
import json
import base64
class Member(models.Model):
# auto-generated ids
uuid = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False)
secret = models.CharField(
max_length=20, default=random_secret, editable=False)
# member info
organization = models.ForeignKey(
'Organization', on_delete=models.CASCADE, related_name='members')
member_id = models.CharField('Mitglieder-Nr.', max_length=20)
name = models.CharField('Name', max_length=100)
valid_since = models.DateField('Mitglied seit')
valid_until = models.DateField('Mitglied bis', blank=True, null=True)
image = FileWithImagePreview(
'Bild', blank=True, null=True,
upload_to=overwrite_upload, validators=[ImageValidator.validate],
help_text=ImageValidator.help_text)
additional = models.JSONField(
'Zusätzliche Daten (JSON)', blank=True, null=True)
class Meta:
verbose_name = 'Mitglied'
verbose_name_plural = 'Mitglieder'
def __str__(self) -> str:
return self.name
@property
def export_url(self) -> str:
return self.organization.exportBaseUrl + \
f"#{self.organization.slug}/{self.uuid}/{self.secret}"
@property
def export_os_path(self) -> PosixPath:
return settings.EXPORT_PATH / self.organization.slug / str(self.uuid)
@property
def image_save_url(self) -> str:
return f'{self.organization.slug}/{self.uuid}'
@property
def image_os_path(self) -> PosixPath:
return settings.MEDIA_ROOT / self.organization.slug / str(self.uuid)
@property
def json(self):
return json.dumps({
'name': self.name,
'org': self.organization.name,
'id': self.member_id,
'valid_since': str(self.valid_since or ''),
'valid_until': str(self.valid_until or ''),
'data': self.additional or '',
'img': base64.b64encode(self.image.read()).decode(
'utf-8') if self.image else None,
})
@property
def json_encrypted(self) -> bytes:
return encrypt(self.json, self.secret)
def export(self):
os.makedirs(self.export_os_path.parent, exist_ok=True)
with open(self.export_os_path, 'wb') as fp:
fp.write(self.json_encrypted)
def save(self, *args, **kwargs):
if self.pk:
try:
prev = Member.objects.get(pk=self.pk)
if prev.image != self.image:
prev.image.delete(save=False)
except Member.DoesNotExist:
pass
self.export()
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if self.image:
self.image.delete(save=False)
if os.path.isfile(self.export_os_path):
os.remove(self.export_os_path)
return super().delete(*args, **kwargs)

View File

@@ -0,0 +1,53 @@
from django.conf import settings
from django.db import models
import os
import shutil
import typing
if typing.TYPE_CHECKING:
from app.models.member import Member
class Organization(models.Model):
name = models.CharField('Name', max_length=100)
slug = models.SlugField('URL Slug', unique=True, max_length=50)
exportBaseUrl = models.URLField('Export Base-URL', max_length=200)
members: 'models.QuerySet[Member]'
class Meta:
verbose_name = 'Organisation'
verbose_name_plural = 'Organisationen'
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs):
if self.pk:
try:
prev = Organization.objects.get(pk=self.pk)
if prev.slug != self.slug:
renameSlug(prev.slug, self.slug)
except Organization.DoesNotExist:
pass
return super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
deleteSlug(self.slug)
return super().delete(*args, **kwargs)
def renameSlug(oldSlug: str, newSlug: str):
if newSlug == oldSlug:
return
oldExportPath = settings.EXPORT_PATH / oldSlug
oldMediaPath = settings.MEDIA_ROOT / oldSlug
if os.path.exists(oldExportPath):
os.rename(oldExportPath, settings.EXPORT_PATH / newSlug)
if os.path.exists(oldMediaPath):
os.rename(oldMediaPath, settings.MEDIA_ROOT / newSlug)
def deleteSlug(oldSlug: str):
shutil.rmtree(settings.EXPORT_PATH / oldSlug, ignore_errors=True)
shutil.rmtree(settings.MEDIA_ROOT / oldSlug, ignore_errors=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1000 1000">
<rect fill="#000" y="112.5" rx="108" width="1000" height="775"/>
<g transform="translate(50,50)scale(.9)">
<rect fill="#fff" y="125" rx="80" width="1000" height="750"/>
<g fill="#43768F">
<rect x="62" y="187" rx="17" width="876" height="63"/>
<rect x="62" y="207" width="876" height="43"/>
<circle cx="282" cy="435" r="94"/>
<path d="M438.2,654.1c0-17.6-1.2-33.9-3.7-49.1c-2.5-15.1-6.4-29.8-11.9-44c-5.5
-14.2-13.8-25.3-24.9-33.4c-11-8.1-24.3-12.2-39.6-12.2c-20.8,20.8-46.2,31.2
-76.2,31.2s-55.4-10.4-76.2-31.2c-15.3,0-28.5,4.1-39.6,12.2c-11.1,8.1-19.4,19.3
-24.9,33.4c-5.5,14.2-9.5,28.8-11.9,44c-2.4,15.2-3.7,31.5-3.7,49.1c0,17.9,5.1,
33.1,15.4,45.6c10.3,12.5,22.5,18.8,36.9,18.8h207.9c14.3,0,26.6-6.2,36.9-18.8
C433.2,687.2,438.2,672,438.2,654.1z"/>
<rect x="500" y="375" width="375" height="63"/>
<rect x="500" y="500" width="190" height="63"/>
<rect x="750" y="500" width="125" height="63"/>
<rect x="500" y="625" width="375" height="63"/>
</g></g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,14 @@
<p>
{% if widget.is_initial %}
<img src="{{MEDIA_URL}}{{widget.value}}" style="max-width: 150px; max-height: 150px; margin: 8px;" />
{% if not widget.required %}
<span class="clearable-file-input">
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
</span>
{% endif %}
<br>
<span>{{ widget.input_text }}:</span>
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</p>

24
backend/app/urls.py Normal file
View File

@@ -0,0 +1,24 @@
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from django.urls import path
from django.views.static import serve
from app.views import EncryptedJsonREST
def ensure_authenticated(request: HttpRequest):
if not request.user.is_authenticated:
raise PermissionDenied()
def protected_serve(request, **kwargs):
ensure_authenticated(request)
return serve(request, **kwargs)
urlpatterns = []
if settings.API_ENABLED:
urlpatterns.append(
path('api/json/<str:org>/<uuid:uuid>', EncryptedJsonREST.as_view()))

28
backend/app/utils.py Normal file
View File

@@ -0,0 +1,28 @@
from django.utils.crypto import get_random_string
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import Hash, SHA256
import os
import typing
if typing.TYPE_CHECKING:
from app.models.member import Member
def random_secret():
return get_random_string(16)
def overwrite_upload(instance: 'Member', filename: str):
if os.path.isfile(instance.image_os_path):
os.remove(instance.image_os_path)
return instance.image_save_url
def encrypt(plaintext: str, key: str):
digest = Hash(SHA256())
digest.update(key.encode('utf8'))
aesgcm = AESGCM(digest.finalize())
iv = os.urandom(12)
ct = aesgcm.encrypt(iv, plaintext.encode('utf8'), iv)
return iv + ct

32
backend/app/views.py Normal file
View File

@@ -0,0 +1,32 @@
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.views import View
from django.views.static import serve
from app.models import Member
class EncryptedJsonREST(View):
http_method_names = ['get']
def get(self, request: HttpRequest, *args, **kwargs):
return serveStaticJson(request, **kwargs)
# return serveDynamicJson(**kwargs)
def memberOr404(kwargs: 'dict[str, str]'):
org, uuid = kwargs['org'], kwargs['uuid']
return get_object_or_404(Member, uuid=uuid, organization__slug=org)
def serveStaticJson(request: HttpRequest, **kwargs):
org, uuid = kwargs['org'], kwargs['uuid']
return serve(
request, path=f'{org}/{uuid}', document_root=settings.EXPORT_PATH)
def serveDynamicJson(**kwargs):
mem = memberOr404(kwargs)
return HttpResponse(
mem.json_encrypted, content_type='application/octet-stream')

View File

16
backend/config/asgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
ASGI config for 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/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

140
backend/config/settings.py Normal file
View File

@@ -0,0 +1,140 @@
"""
Django settings for project.
Generated by 'django-admin startproject' using Django 4.2.4.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import os
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/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'insecure-$a^nh$d!tgut43^91@')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = SECRET_KEY.startswith('insecure') or \
os.environ.get('DEBUG', '0').lower() not in ['false', 'no', '0']
ALLOWED_HOSTS = []
ALLOWED_HOSTS.extend(
filter(
None,
os.environ.get('ALLOWED_HOSTS', '*').split(','),
)
)
CSRF_TRUSTED_ORIGINS = ['https://' + x for x in ALLOWED_HOSTS]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'app.apps.AppConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data' / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/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/4.2/topics/i18n/
LANGUAGE_CODE = 'de'
TIME_ZONE = 'Europe/Berlin'
USE_I18N = True
USE_TZ = False
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'data' / 'static'
MEDIA_URL = 'upload/'
MEDIA_ROOT = BASE_DIR / 'data' / 'upload'
# custom
API_ENABLED = False
EXPORT_PATH = BASE_DIR / 'data' / 'export'
# enable sub-path URLs
URL_SUBPATH = os.environ.get('URL_SUBPATH') or ''
URL_SUBPATH = (URL_SUBPATH.strip('/') + '/').lstrip('/')
if URL_SUBPATH:
STATIC_URL = URL_SUBPATH + STATIC_URL
MEDIA_URL = URL_SUBPATH + MEDIA_URL

31
backend/config/urls.py Normal file
View File

@@ -0,0 +1,31 @@
"""
URL configuration for project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/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.conf import settings
from django.conf.urls.static import static
from django.urls import include, path
from app.urls import protected_serve
urlpatterns = [
path(settings.URL_SUBPATH, include('app.urls')),
path(settings.URL_SUBPATH + 'edit/', admin.site.urls),
# enable authenticated upload file access
path(settings.MEDIA_URL.lstrip('/') + '<path:path>', protected_serve,
{'document_root': settings.MEDIA_ROOT}),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

16
backend/config/wsgi.py Normal file
View File

@@ -0,0 +1,16 @@
"""
WSGI config for 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/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

22
backend/manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/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', 'config.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()

2
backend/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Django==4.2.5
cryptography==41.0.4

7
backend/scripts/on-deploy.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
python manage.py collectstatic --noinput
python manage.py migrate
# python -m uvicorn --port 8099 config.asgi:application
python -m gunicorn -b 0.0.0.0:8099 -k uvicorn.workers.UvicornWorker config.asgi:application

4
backend/scripts/on-init.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
python manage.py migrate
python manage.py createsuperuser

26
backend/setup.py Normal file
View File

@@ -0,0 +1,26 @@
from setuptools import setup, find_packages
with open('README.md') as fp:
longdesc = fp.read()
setup(
name='ausweis',
version='0.9.0',
author='OWBA',
description='Django app for digital membership cards',
long_description=longdesc,
long_description_content_type="text/markdown",
url='https://github.com/owba/ausweis',
license='MIT',
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: BSD License',
'Environment :: Web Environment',
'Framework :: Django',
'Operating System :: OS Independent',
],
python_requires='>=3.8',
packages=find_packages(),
scripts=['manage.py'],
install_requires=[],
)

View File

@@ -0,0 +1,5 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
<link rel="shortcut icon" href="{% static 'favicon.ico' %}" />
{% endblock %}