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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

45
Makefile Normal file
View File

@@ -0,0 +1,45 @@
SSH ?= vps3:~/ausweis-docker
.PHONY: help
help:
@echo 'defaults: SSH=$(SSH)'
@echo
@echo 'available commands:'
@echo ' - for docker: start, stop, nuke, init, gen-key'
@echo ' - for rsync: push [SSH=xxx]'
# ----------
# docker
# ----------
.PHONY: init
init:
docker-compose exec app sh /django_project/scripts/on-init.sh
.PHONY: gen-key
gen-key:
docker-compose exec app python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
.PHONY: start
start:
docker-compose up -d --force-recreate
.PHONY: stop
stop:
docker-compose stop
.PHONY: nuke
nuke:
docker rm ausweis || true
docker network rm ausweis || true
docker image rm ausweis || true
# ----------
# rsync
# ----------
.PHONY: push
push:
rsync -av --delete --exclude=backend/data --exclude=.venv --exclude=.git --exclude=.DS_Store \
backend docker-compose.yml Makefile \
$(SSH)/

125
README.md Normal file
View File

@@ -0,0 +1,125 @@
# Ausweis
A Django app for digital membership cards.
![example member card](docs/card.png)
## Features
- Encrypted personal data
- Cards with limited validity and auto-expire
- Daily validity and animation to prohibit screenshots
- Access your cards as bookmark or Homescreen app
- Fully customizable card design (html,css,js knowledge required)
- Extensible custom fields
- Static frontend with separate backend for card management (can be used offline)
## Install (Docker)
### 1. Clone this repo or download the code to your server.
Alternatively you can rsync the content to your server:
```sh
make push SSH=user@domain:/path/to/ausweis
```
(this will rsync: `backend/*`, `Makefile`, `docker-compose.yml`)
### 2. Create your environment file
You must generate a secure key first. Alternatively, you can run `make gen-key` to generate a new key but this will only work if the container is already running (next step). To apply the new key, you must reboot the container (`make stop start`).
File: `/path/to/ausweis/.env`
```sh
DJANGO_SECRET_KEY='your-fancy-secret'
ALLOWED_HOSTS=mydomain
#URL_SUBPATH=backend
DEBUG=0
```
Then set the permissions of the file to be readable only by the docker user:
```sh
chown root:root "/path/to/ausweis/.env"
chmod 600 "/path/to/ausweis/.env"
```
### 3. Start the container
```sh
make start
make init # only needed the first time (creates admin user)
```
### 4. Configure webserver
You can run this server either on same url or split the backend and frontend part into two. Refer to the `nginx.conf` files in `frontend`.
Make sure to configure the file properly. See [frontend/README.md](frontend/README.md) for further instructions.
You may need to create an SSL certificat first (e.g., Certbot).
### 5. Test your server
Open the administration page to create new cards. Depending on your configuration this URL will be hosted at `https://your-domain.com/<subpath>/edit/`.
## Install (vanilla)
**Not tested** but should be similar to the Docker setup. You will need to install `pip install gunicorn uvicorn` and run the Django application directly (see `scripts/on_deploy.sh`). Further, you will need to update `STATIC_ROOT` and `EXPORT_PATH` in your `settings.py`. (And probably check how to pass the env-file to `gunicorn`). Finally, you should persist the service to respawn after a system reboot.
## Next steps
So your server is running and you have created your first card. What's next?
- Modify the frontend html example to match the design of your business / workshop (see [Frontend](#frontend) below).
- If you need more fields, use the "Additional data" json (e.g., `{"extra":"Value"}`). And in your html: `<i id="extra">placeholder</i>`.
- You probably want to modify the webmanifest so that you can "install" your member card as an "app" (incl. `apple-touch-icon` etc.). Have a look at the [real favicon generator](https://realfavicongenerator.net/) or create your own.
## Develop
### Frontend
1. Start a webserver from `./frontend/example-html/`.
A simple static server is sufficient (e.g., `python -m SimpleHTTPServer 80` or `php -S 0:80`)
2. Open http://127.0.0.1/#org/d9498400-2640-442e-a092-6b4537a9b74d/rpsLvt6armrp
### Backend
1. Prepare virtual environment and start dev server:
```sh
cd backend
python3 -m venv .venv
. ./.venv/bin/activate
pip install -r requirements.txt
./manage.py runserver
```
2. Open http://127.0.0.1:8000/edit/
3. Create a member and an organization (with URL `http://127.0.0.1`)
4. Create a symlink for the data folder to use your backend json files directly:
```sh
cd frontend/example-html
ln -s ../../backend/data/export data
# alternative: edit `EXPORT_PATH` in `backend/config/settings.py`
```

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 %}

32
docker-compose.yml Executable file
View File

@@ -0,0 +1,32 @@
version: '3'
services:
app:
container_name: ausweis
build:
context: ./backend
# dockerfile: .
pull_policy: build
ports:
- 127.0.0.1:8099:8099
image: ausweis:latest
working_dir: /django_project
environment:
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
URL_SUBPATH: ${URL_SUBPATH:-}
DEBUG: ${DEBUG:-0}
volumes:
- volume-ausweis:/django_project/data
- /srv/http/de-owba-mitglied/data:/django_project/data/export:rw
- /srv/http/de-owba-mitglied/app_static:/django_project/data/static:rw
restart: unless-stopped
networks:
- network-ausweis
volumes:
volume-ausweis:
name: ausweis
networks:
network-ausweis:
name: ausweis

BIN
docs/card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

68
frontend/README.md Normal file
View File

@@ -0,0 +1,68 @@
# Webserver config
There are two ways to host this app:
- `nginx-split-path.conf`: backend and frontend are served at different URLs
- `nginx-same-path.conf`: backend and frontend use the same base URL
Both examples use `/srv/http/my-domain/` as the base folder, make sure it exists.
The server will create files at these locations:
```sh
/srv/http/my-domain/ # base folder, make sure it exists
/srv/http/my-domain/backend_data/ # holds the encrypted json files
/srv/http/my-domain/backend_static/ # holds static js files (mostly django admin)
/srv/http/my-domain/frontend/ # place your frontend html here (e.g. content of example-html)
/srv/http/my-domain/root/ # root-level files ("/") (only split-path)
```
## Split path
Since both parts are served separately, you can host the server on two different machines (or two different subpaths). The frontend is a purely static server. You can use a CDN if you want.
If you serve the backend on a subpath (not root "/"), you need to pass that subpath to the env file. E.g., `URL_SUBPATH=my/sub/path`.
If you use two different servers, you have to transfer the encryted json files to the frontend server somehow (e.g., with rsync or a tiny REST API). Or use the integrated API `api/json/<org>/<uuid>` (disabled by default). Though the other way (pushing data from backend to frontend) is more favorable. That way you will not expose the backend server to the public.
## Same path
With this config, both frontend and backend are served from the root of the domain ("/"). You can still serve them from two different servers, but its easy to guess the url and discover the other server.
If you use two different servers, you will need to declare the exception explicitly (namely, `edit`, `upload`, `static`, and `api`). If the backend URLs change, you will need to update the config too. But in the given example, nginx will fallback to the backend server whever a file cannot be found.
**Note:** you can not have files in your frontend which are named like any of the backend URLs. For example, if you create a static `upload` or `edit` folder in your frontend code, it would probably break the administration backend (precedence).
## Comparison
Assuming we configured the split-path config to use `frontend="card"` and `backend="hidden-service"` the URLs would be:
| | Split path | Same path |
|--------|------------------------------|---------------|
|Frontend| /card/#org/id/pw | /#org/id/pw |
|Backend | /hidden-service/edit/ | /edit/ |
|Static | /hidden-service/static/ | /static/ |
|Data | /card/data/org/id | /data/org/id/ |
|API | /hidden-service/api/json/... | /api/json/... |
|Root | /other-service/ | |
## Security considerations
A dynamic server (like Django) is always a security risk. You should limit public access wherever possible. For example:
- If you are the only person managing member cards, you can run the backend in a local-only environment and just sync the changes with rsync to a static frontend server.
- If you run the backend as a public server, you can try to limit the access. For example, by allowing only known IP Adresses (again, only if there are few people managing the cards and/or the connection location is fixed, e.g., business network).
- If your backend is public, at least do not use common URL paths. For example, `/admin/` is easy to guess and most crawlers will try these locations.
- Both configs allow you to run the backend and frontend on separate servers. This separates the sensitive data (Django app with unencrypted raw data) from the publicly accessible data (encrypted json).
- If you need to communicate between both servers, try to push the data from backend to frontend instead of the other way around. This way your frontend stays static and an attacker will not discover the backend server just by analyzing the web traffic. (attention: you may still expose it through Certificate Transparency Logs)
- Needless to say, communication between servers must be authenticated. Or else someone can just create new member cards arbitrarily.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,18 @@
<svg viewBox="0 0 657.3 53.6" xmlns="http://www.w3.org/2000/svg">
<path d="m3.8 53.1c-1 0-1.8-.2-2.6-.6-.8-.5-1.2-1.1-1.2-2v-47.5c0-.8.4-1.4 1.1-1.9.7-.4 1.5-.6 2.3-.6h25.3c.9 0 1.5.4 1.9 1.1s.6 1.5.6 2.2c0 .9-.2 1.7-.6 2.4s-1 1-1.8 1h-21v16.5h11.2c.8 0 1.4.3 1.8.9s.6 1.3.6 2.2c0 .7-.2 1.3-.6 2-.4.6-1 1-1.9 1h-11.3v20.8c0 .9-.4 1.5-1.2 1.9-.7.4-1.6.6-2.6.6z"/>
<path d="m30.2 53.1c-.7 0-1.4-.1-2.2-.4s-1.4-.7-1.9-1.1c-.5-.5-.7-1-.7-1.6 0-.1 0-.4.1-.6l14.4-46.8c.3-.9.9-1.5 1.8-1.9s1.9-.6 3-.6 2.1.2 3.1.6c.9.4 1.5 1.1 1.8 1.9l14.3 46.7c.1.3.1.5.1.6 0 .6-.3 1.1-.8 1.6s-1.1.9-1.9 1.2c-.7.3-1.5.4-2.2.4-.6 0-1.2-.1-1.6-.4-.5-.2-.8-.6-1-1.2l-3-10.2h-17.7l-2.9 10.2c-.1.6-.5 1-1 1.2s-1 .4-1.7.4zm7.3-18h14.4l-7.2-24.7z"/>
<path d="m73.1 53.1c-1 0-1.8-.2-2.4-.7s-.9-1.1-.9-1.7v-47.7c0-.8.4-1.4 1.1-1.9.7-.4 1.5-.6 2.3-.6h13.1c2.6 0 5 .3 7.2 1s4 1.9 5.3 3.7 2 4.4 2 7.7c0 3-.5 5.5-1.6 7.5s-2.6 3.4-4.5 4.2c1.5.6 2.8 1.3 3.9 2.3 1.1.9 2 2.2 2.6 3.7.6 1.6.9 3.6.9 6v1.4c0 3.7-.6 6.6-1.9 8.9-1.2 2.2-3 3.8-5.1 4.8-2.2 1-4.6 1.5-7.3 1.5h-14.7zm4.2-31.6h8.6c2.4 0 4.3-.6 5.4-1.9 1.2-1.2 1.8-3.1 1.8-5.5 0-1.6-.3-3-.9-4s-1.5-1.8-2.6-2.2c-1.1-.5-2.4-.7-3.9-.7h-8.5v14.3zm0 24.8h9c2.6 0 4.6-.7 6-2.1s2.1-3.7 2.1-6.8v-1.2c0-3.2-.7-5.4-2.1-6.7s-3.4-1.9-6-1.9h-9z"/>
<path d="m127.3 53.1c-.8 0-1.6-.2-2.3-.6s-1.1-1.1-1.1-1.9v-47.7c0-.9.4-1.5 1.2-1.9s1.7-.6 2.6-.6c1 0 1.8.2 2.6.6s1.2 1 1.2 1.9v43.4h18.7c.8 0 1.3.3 1.7 1s.6 1.5.6 2.3-.2 1.6-.6 2.3-1 1.1-1.7 1.1h-22.9z"/>
<path d="m159 53.1c-.7 0-1.4-.1-2.2-.4s-1.4-.7-1.9-1.1c-.5-.5-.7-1-.7-1.6 0-.1 0-.4.1-.6l14.3-46.7c.3-.9.9-1.5 1.8-1.9s1.9-.6 3-.6 2.1.2 3.1.6c.9.4 1.5 1.1 1.8 1.9l14.3 46.7c.1.3.1.5.1.6 0 .6-.3 1.1-.8 1.6s-1.1.9-1.9 1.2c-.7.3-1.5.4-2.2.4-.6 0-1.2-.1-1.6-.4-.5-.2-.8-.6-1-1.2l-3-10.2h-17.9l-2.9 10.2c-.1.6-.5 1-1 1.2-.2.1-.7.3-1.4.3zm7.3-18h14.4l-7.2-24.7z"/>
<path d="m201.9 53.1c-1 0-1.8-.2-2.4-.7s-.9-1.1-.9-1.7v-47.7c0-.8.4-1.4 1.1-1.9.7-.4 1.5-.6 2.3-.6h13c2.6 0 5 .3 7.2 1s4 1.9 5.3 3.7 2 4.4 2 7.7c0 3-.5 5.5-1.6 7.5s-2.6 3.4-4.5 4.2c1.5.6 2.8 1.3 3.9 2.3 1.1.9 2 2.2 2.6 3.7.6 1.6.9 3.6.9 6v1.4c0 3.7-.6 6.6-1.9 8.9-1.2 2.2-3 3.8-5.1 4.8-2.2 1-4.6 1.5-7.3 1.5h-14.6zm4.2-31.6h8.6c2.4 0 4.3-.6 5.4-1.9 1.2-1.2 1.8-3.1 1.8-5.5 0-1.6-.3-3-.9-4s-1.5-1.8-2.6-2.2c-1.1-.5-2.4-.7-3.9-.7h-8.5v14.3zm0 24.8h9c2.6 0 4.6-.7 6-2.1s2.1-3.7 2.1-6.8v-1.2c0-3.2-.7-5.4-2.1-6.7s-3.4-1.9-6-1.9h-9z"/>
<path d="m256.5 53.1c-1 0-1.8-.2-2.6-.6s-1.2-1.1-1.2-1.9v-46.4c0-1.4.4-2.4 1.2-3s1.7-.8 2.6-.8 1.7.1 2.4.4 1.3.7 1.9 1.3 1.2 1.5 1.8 2.6l10.1 19.1 10.2-19.1c.6-1.1 1.2-2 1.8-2.6s1.2-1.1 1.8-1.3c.7-.3 1.5-.4 2.4-.4 1 0 1.9.3 2.7.8.8.6 1.2 1.5 1.2 3v46.3c0 .8-.4 1.4-1.2 1.9s-1.7.6-2.6.6c-1 0-1.8-.2-2.6-.6s-1.2-1.1-1.2-1.9v-34.5l-9.6 17.9c-.4.6-.8 1.1-1.3 1.3-.5.3-1 .4-1.5.4-.4 0-.9-.1-1.4-.4-.5-.2-.9-.7-1.2-1.4l-9.6-18.3v35c0 .8-.4 1.4-1.2 1.9s-1.9.7-2.9.7z"/>
<path d="m303.1 53.1c-.7 0-1.4-.1-2.2-.4s-1.4-.7-1.9-1.1c-.5-.5-.7-1-.7-1.6 0-.1 0-.4.1-.6l14.3-46.7c.3-.9.9-1.5 1.8-1.9s1.9-.6 3-.6 2.1.2 3.1.6c.9.4 1.5 1.1 1.8 1.9l14.3 46.7c.1.3.1.5.1.6 0 .6-.3 1.1-.8 1.6s-1.1.9-1.9 1.2c-.7.3-1.5.4-2.2.4-.6 0-1.2-.1-1.6-.4-.5-.2-.8-.6-1-1.2l-3-10.2h-17.9l-2.9 10.2c-.1.6-.5 1-1 1.2-.2.1-.8.3-1.4.3zm7.3-18h14.4l-7.2-24.7z"/>
<path d="m346.4 53.1c-1 0-1.8-.2-2.6-.6s-1.2-1.1-1.2-1.9v-47.7c0-.9.4-1.5 1.2-1.9s1.7-.6 2.6-.6c1 0 1.8.2 2.6.6s1.2 1 1.2 1.9v21.9l17.6-23.3c.5-.7 1.2-1.1 2.2-1.1.6 0 1.3.2 2 .6s1.3.9 1.7 1.5c.5.6.7 1.2.7 1.9 0 .2 0 .4-.1.7s-.2.6-.4.8l-13.8 17.3 16.4 25c.3.4.4.9.4 1.4 0 .6-.2 1.2-.6 1.8s-1 1-1.7 1.4-1.4.5-2.1.5c-.5 0-1-.1-1.5-.3s-.9-.6-1.2-1.1l-14.7-22.2-4.7 5.9v15c0 .8-.4 1.4-1.2 1.9s-1.9.6-2.8.6z"/>
<path d="m385 53.1c-.8 0-1.6-.2-2.3-.6s-1.1-1.1-1.1-1.9v-47.6c0-.8.4-1.4 1.1-1.9s1.5-.6 2.3-.6h25.6c.9 0 1.6.4 1.9 1.1.4.7.6 1.5.6 2.2 0 .9-.2 1.7-.6 2.4s-1.1 1-1.9 1h-21.4v16.5h11.1c.8 0 1.4.3 1.9.9.4.6.6 1.3.6 2.2 0 .7-.2 1.3-.6 2-.4.6-1 1-1.9 1h-11.1v16.6h21.4c.8 0 1.4.3 1.9 1 .4.7.6 1.5.6 2.4 0 .8-.2 1.5-.6 2.2s-1 1.1-1.9 1.1z"/>
<path d="m447.5 53.3c-.6 0-1.2-.2-1.7-.6s-.9-1-1.3-1.6l-10.5-20.6h-7.2v20.1c0 .8-.4 1.4-1.2 1.9s-1.7.6-2.6.6c-1 0-1.8-.2-2.6-.6s-1.2-1.1-1.2-1.9v-47.7c0-.7.2-1.2.7-1.7s1.1-.7 1.9-.7h13.7c3 0 5.8.4 8.3 1.3s4.5 2.4 6 4.5 2.2 5.1 2.2 8.8c0 2.9-.4 5.3-1.3 7.2s-2.1 3.4-3.6 4.6c-1.5 1.1-3.2 2-5.1 2.5l10.1 18.9c.1.1.2.3.2.5s.1.4.1.5c0 .6-.2 1.2-.7 1.8s-1.1 1.1-1.8 1.5c-.9.5-1.6.7-2.4.7zm-20.7-29h8.6c2.7 0 4.8-.6 6.4-1.9s2.4-3.5 2.4-6.6-.8-5.3-2.4-6.6-3.8-1.9-6.4-1.9h-8.6z"/>
<path d="m486.6 53.6c-3.1 0-5.9-.5-8.3-1.4s-4.3-2-5.6-3.3-2-2.6-2-3.8c0-.6.2-1.2.5-1.8s.7-1.2 1.2-1.6c.5-.5 1-.7 1.6-.7.7 0 1.3.3 1.9.8.6.6 1.3 1.2 2.2 2s1.9 1.4 3.2 2 3 .9 5 .9c1.7 0 3.3-.3 4.6-.8 1.4-.6 2.4-1.4 3.2-2.5s1.2-2.6 1.2-4.3c0-1.8-.4-3.3-1.3-4.5-.8-1.2-2-2.2-3.4-3s-2.9-1.6-4.6-2.2c-1.6-.7-3.3-1.4-5-2.2s-3.2-1.7-4.6-2.8-2.5-2.4-3.3-4.1-1.3-3.7-1.3-6.2c0-2.6.5-4.8 1.5-6.6s2.3-3.2 3.9-4.2c1.6-1.1 3.4-1.8 5.4-2.3s3.9-.7 5.8-.7c1.2 0 2.5.1 4 .3s2.9.5 4.3.9 2.5.9 3.5 1.6c.9.7 1.4 1.5 1.4 2.4 0 .5-.1 1-.4 1.7s-.6 1.2-1 1.7c-.5.5-1 .7-1.8.7-.6 0-1.3-.2-2.1-.7s-1.8-.9-3-1.4-2.8-.7-4.9-.7c-1.7 0-3.3.2-4.6.7-1.4.5-2.4 1.2-3.2 2.1s-1.2 2.2-1.2 3.7.4 2.8 1.3 3.8c.8 1 2 1.8 3.3 2.5 1.4.7 2.9 1.4 4.6 2s3.3 1.4 5 2.2 3.2 1.9 4.6 3.1 2.5 2.7 3.4 4.6c.8 1.8 1.3 4.1 1.3 6.9 0 3.5-.7 6.3-2.1 8.6s-3.4 4-5.8 5.1c-2.4 1-5.2 1.5-8.4 1.5z"/>
<path d="m514 53.1c-1 0-1.8-.2-2.6-.6s-1.2-1-1.2-1.9v-47.7c0-.7.3-1.2.8-1.7.6-.5 1.3-.7 2.1-.7h14c2.9 0 5.6.5 8 1.5s4.3 2.7 5.7 5 2.1 5.4 2.1 9.3v.6c0 3.8-.7 6.9-2.2 9.3-1.4 2.3-3.4 4-5.8 5.1s-5.2 1.6-8.2 1.6h-8.9v17.8c0 .9-.4 1.5-1.2 1.9-.8.3-1.6.5-2.6.5zm3.8-26.4h8.9c2.7 0 4.8-.8 6.3-2.3s2.3-3.9 2.3-7.1v-.9c0-3.2-.8-5.5-2.3-7s-3.6-2.3-6.3-2.3h-8.9z"/>
<path d="m547.3 53.1c-.7 0-1.4-.1-2.2-.4s-1.4-.7-1.9-1.1c-.5-.5-.7-1-.7-1.6 0-.1 0-.4.1-.6l14.4-46.8c.3-.9.9-1.5 1.8-1.9s1.9-.6 3-.6 2.1.2 3.1.6c.9.4 1.5 1.1 1.8 1.9l14.3 46.6c.1.3.1.5.1.6 0 .6-.3 1.1-.8 1.6s-1.1.9-1.9 1.2c-.7.3-1.5.4-2.2.4-.6 0-1.2-.1-1.6-.4-.5-.2-.8-.6-1-1.2l-3-10.2h-17.9l-2.7 10.3c-.1.6-.5 1-1 1.2s-1 .4-1.7.4zm7.3-18h14.4l-7.2-24.7z"/>
<path d="m602.2 53.5c-2.9 0-5.5-.5-8-1.6-2.4-1.1-4.4-2.8-5.8-5.1-1.4-2.4-2.2-5.5-2.2-9.4v-21c0-3.9.7-7 2.2-9.4 1.4-2.4 3.4-4.1 5.8-5.1 2.4-1.1 5.1-1.6 8-1.6 3.3 0 6.2.6 8.6 1.7s4.3 2.7 5.7 4.6 2.1 4.2 2.1 6.7c0 1.6-.3 2.7-.9 3.2s-1.6.8-2.9.8c-1.2 0-2.1-.2-2.7-.6-.7-.4-1-1-1.1-1.9 0-.6-.2-1.4-.4-2.2-.2-.9-.6-1.7-1.1-2.6-.5-.7-1.4-1.4-2.5-2s-2.6-.8-4.5-.8c-2.8 0-4.9.8-6.4 2.3s-2.2 3.9-2.2 7v21c0 3.2.8 5.5 2.3 7s3.7 2.3 6.6 2.3c1.8 0 3.3-.3 4.4-.8 1.1-.6 1.9-1.2 2.4-2.1.5-.8.9-1.7 1.1-2.7.2-.9.3-1.8.4-2.6 0-.9.4-1.6 1.1-1.9.7-.4 1.6-.6 2.6-.6 1.3 0 2.3.3 3 .8.6.5.9 1.6.9 3.2 0 2.6-.7 4.9-2.1 6.9s-3.3 3.6-5.7 4.8c-2.5 1.1-5.4 1.7-8.7 1.7z"/>
<path d="m629.1 53.1c-.8 0-1.6-.2-2.3-.6s-1.1-1.1-1.1-1.9v-47.6c0-.8.4-1.4 1.1-1.9s1.5-.6 2.3-.6h25.6c.9 0 1.6.4 1.9 1.1.4.7.6 1.5.6 2.2 0 .9-.2 1.7-.6 2.4s-1.1 1-1.9 1h-21.4v16.5h11.1c.8 0 1.4.3 1.9.9.4.6.6 1.3.6 2.2 0 .7-.2 1.3-.6 2-.4.6-1 1-1.9 1h-11.1v16.6h21.4c.8 0 1.4.3 1.9 1 .4.7.6 1.5.6 2.4 0 .8-.2 1.5-.6 2.2s-1 1.1-1.9 1.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 547 546.8" xmlns="http://www.w3.org/2000/svg">
<path fill="#0e9a48" d="m208.1 485.8v-65.2c2.3-.9 4.6-2 6.8-3.3 31.1-18.3 34.4-70.4 7.3-116.3s-74.2-68.2-105.3-49.9-34.4 70.4-7.3 116.3c4.4 7.4 9.3 14.2 14.5 20.3v48.4l-67-38.3v-240.1l-23.5-15.5c-21.4 39-33.6 83.7-33.6 131.3 0 147.9 117.4 268.3 264.1 273.2v-28.2z"/>
<path fill="#cb2026" d="m130.3 104.2 41 24.3c-.8 6.7-.8 14.1-.8 16.7.4 36.1 49.2 63.5 102.5 63s91.2-32.8 90.9-64.6c-.3-36.1-35-64.3-88.3-65.9-18-.5-35.4 3.1-43.3 4.6l-29.9-19.1 71-40.5 209.4 121.7 24-13.2c-23.1-38-55.8-70.9-97.1-94.6-128-73.9-290.9-32.3-368.3 92.5l23.8 15z"/>
<path fill="#35469d" d="m490 236.5-44.5 23.3c-1.6-.3-11.5-9.5-13.7-10.8-31.5-17.6-77.9 5.7-103.9 52.2-25.9 46.5-23.7 99.8 7.8 117.3 31.5 17.6 78.5-3.5 104.4-50 4.2-7.5 9.8-24.9 12.4-32.6l37.9-21.9-.2 83.3-208.9 122 .1 27.5c44.4-1.2 89.2-13.4 130.3-37.4 127.6-74.8 172.3-236.9 102.3-366l-24 13.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 914 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 70 90" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h70v90h-70z" fill="#ddd"/><g fill="#888"><path d="m66.2 73.1c0 3.6-1 6.6-3 9.1-2.1 2.5-4.5 3.8-7.3 3.8h-41.8c-2.9 0-5.4-1.2-7.3-3.8s-3-5.5-3-9.1c0-2.8.2-5.4.4-7.9s.8-4.9 1.5-7.4 1.7-4.6 2.9-6.4 2.7-3.2 4.6-4.4 4.1-1.7 6.6-1.7c4.3 4.2 9.4 6.2 15.3 6.2s11-2.1 15.3-6.2c2.5 0 4.6.5 6.6 1.7s3.4 2.6 4.6 4.4 2.1 3.9 2.9 6.4c.7 2.5 1.2 5 1.5 7.4s.2 5.1.2 7.9z"/><circle cx="35.1" cy="29.9" r="18.7"/></g></svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0" />
<title>Member card</title>
<link rel="stylesheet" href="style.css">
<script src="script.js"></script>
<link rel="manifest" href="./manifest.json">
</head>
<body>
<div id="card">
<div id="msg" data-load="Loading ...">Loading ...</div>
<div id="pass" class="hidden">
<div class="spin"></div>
<header>
<img src="img/header.svg" height="16" style="margin: 4px">
<img src="img/logo.svg" height="60" style="float: right">
</header>
<main>
<img id="img" src="img/no-img.svg">
<div>
<div><span id="name">Name</span></div>
<div>ID: <span id="member_id">42</span></div>
<div>Valid: <span id="valid">on 1/1/1970</span></div>
</div>
</main>
<footer>
Member card valid <span id="valid">on 1/1/1970</span>
</footer>
</div>
</div>
<script>onResize()</script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
{
"name": "Ausweis",
"display": "fullscreen"
}

View File

@@ -0,0 +1,124 @@
async function decrypt(ciphertext, password) {
const pwUtf8 = new TextEncoder().encode(password);
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);
const ctUtf8 = new Uint8Array(ciphertext);
const iv = ctUtf8.slice(0, 12);
const alg = { name: 'AES-GCM', iv: iv, additionalData: iv };
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']);
try {
const buf = await crypto.subtle.decrypt(alg, key, ctUtf8.slice(12));
return new TextDecoder().decode(buf);
} catch (e) {
throw new Error('Decrypt failed')
}
}
function setError(text) {
document.getElementById('msg').innerText = text;
}
function setFields(elem, fields) {
for (const [k, v] of Object.entries(fields)) {
for (const el of elem.querySelectorAll('#' + k)) {
el.innerText = v;
}
}
}
async function apply(data, password) {
const json = JSON.parse(await decrypt(data, password));
const now = new Date().getTime();
const vA = new Date(json['valid_since'] + 'T00:00:00');
if (vA.getTime() > now) {
return setError('Member card not valid yet');
}
const vZ = new Date(json['valid_until'] + 'T23:59:59');
if (vZ.getTime() < now) {
return setError('Member card expired');
}
const pass = document.getElementById('pass');
const imgElem = pass.querySelector('#img');
if (imgElem) {
if (json['img']) {
imgElem.src = 'data:image;base64,' + json['img'];
} else {
imgElem.src = 'img/no-img.svg';
}
}
setFields(pass, {
name: json['name'],
member_id: json['id'],
org_name: json['org'],
valid: 'on ' + new Date().toLocaleDateString('en'),
});
setFields(pass, json['data']); // may overwrite previous fields
const title = 'Member card for ' + json['name'] + ' ' + json['org'];
document.head.querySelector('title').innerText = title;
document.getElementById('msg').classList.add('hidden');
pass.classList.remove('hidden');
}
async function onLoad() {
// reset previous download
document.getElementById('pass').classList.add('hidden');
const msg = document.getElementById('msg');
msg.innerText = msg.dataset.load;
msg.classList.remove('hidden');
// download new data
const [org, uuid, secret] = location.hash.slice(1).split('/');
if (!org || !uuid || !secret) {
return setError('Invalid URL');
}
const res = await fetch('./data/' + org + '/' + uuid);
if (!res.ok) {
return setError('Error loading\n\n' + res.status + ' ' + res.statusText);
}
try {
const data = await res.arrayBuffer();
await apply(data, secret);
} catch (e) {
setError(e);
}
}
// load and parse data
window.onload = onLoad;
// force reload if hash params change
window.addEventListener('hashchange', onLoad, true);
// -------------
// scale-up card
// -------------
function onResize() {
const card = document.getElementById('card');
const sw = window.innerWidth / card.offsetWidth;
const sh = window.innerHeight / card.offsetHeight;
card.style.scale = Math.min(Math.min(sw, sh) * 0.97, 2);
}
window.addEventListener('resize', onResize, true);
window.addEventListener('orientationchange', onResize, true);
screen?.orientation?.addEventListener('change', onResize, true);
// -----------------
// check for updates
// -----------------
lastUpdate = new Date().getTime();
function needsUpdate() {
// reload page if older than 15min
const now = new Date().getTime();
if (now - lastUpdate > 900_000) {
lastUpdate = now;
onLoad();
}
}
// setInterval(needsUpdate, 1000);
window.addEventListener('focus', needsUpdate, true);
window.addEventListener('pageshow', needsUpdate, true);
window.addEventListener('visibilitychange', function () {
!document.hidden && document.visibilityState !== 'hidden' && needsUpdate();
}, true);

View File

@@ -0,0 +1,125 @@
:root {
--w: 85.6mm;
--h: 54mm;
--r: 3.18mm;
--iw: 2.5cm;
--ih: 3.2cm;
--ratio: 2.5 / 3.2;
}
body {
background: #666;
font-family: sans-serif;
}
#msg {
position: absolute;
top: 50%;
transform: translateY(-50%);
text-align: center;
width: 100%;
}
#card {
position: absolute;
background: #fff;
width: var(--w);
height: var(--h);
top: calc(50% - var(--h)/2);
left: calc(50% - var(--w)/2);
border-radius: var(--r);
box-shadow: 0 0 4px;
}
#pass {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
border-radius: var(--r);
font-size: 3mm;
}
header {
padding: 1mm;
border-radius: var(--r) var(--r) 0 0;
max-height: 6mm;
}
footer {
padding: 1mm 3mm;
border-radius: 0 0 var(--r) var(--r);
}
main {
display: flex;
font-size: 1.2em;
}
main>div {
display: flex;
flex-direction: column;
justify-content: center;
gap: 2mm;
text-shadow: 1px 0 #fff, 0 1px #fff, -1px 0 #fff, 0 -1px #fff,
1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff;
}
main>div>div {
display: flex;
gap: 1mm;
}
#img {
max-width: var(--iw);
max-height: var(--ih);
object-fit: cover;
border-radius: 1mm;
margin: 0 2mm;
aspect-ratio: var(--ratio);
}
.hidden {
display: none !important;
}
.spin {
position: absolute;
bottom: 1.2mm;
right: 1.5mm;
width: 2mm;
height: 2mm;
border: .5mm solid transparent;
border-top-color: #fff;
border-bottom-color: #fff;
border-radius: 50%;
animation: spin 2s steps(5) infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@media print {
body {
background: unset;
}
#card {
top: unset;
left: unset;
scale: 1 !important;
}
}
/* Customize appearance */
#pass {
background: url('img/bg.png') 25mm 6mm/60mm no-repeat;
}
footer {
color: #fff;
background: #0E9A48;
}

View File

@@ -0,0 +1,13 @@
async function decrypt(ciphertext, password) {
const pwUtf8 = new TextEncoder().encode(password);
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);
const ctUtf8 = new Uint8Array(Array.from(atob(ciphertext)).map(x => x.charCodeAt(0)));
const alg = { name: 'AES-GCM', iv: ctUtf8.slice(0,12) };
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['decrypt']);
try {
const buf = await crypto.subtle.decrypt(alg, key, ctUtf8.slice(12));
return new TextDecoder().decode(buf);
} catch (e) {
throw new Error('Decrypt failed')
}
}

View File

@@ -0,0 +1,11 @@
async function encrypt(plaintext, password) {
const pwUtf8 = new TextEncoder().encode(password);
const pwHash = await crypto.subtle.digest('SHA-256', pwUtf8);
const iv = crypto.getRandomValues(new Uint8Array(12));
const alg = { name: 'AES-GCM', iv: iv };
const key = await crypto.subtle.importKey('raw', pwHash, alg, false, ['encrypt']);
const ptUint8 = new TextEncoder().encode(plaintext);
const buf = await crypto.subtle.encrypt(alg, key, ptUint8);
const ctStr = Array.from(new Uint8Array(buf)).map(b => String.fromCharCode(b)).join('');
return btoa(String.fromCharCode(...iv) + ctStr);
}

View File

@@ -0,0 +1,49 @@
upstream ausweis { server 127.0.0.1:8099; }
server {
server_name MYDOMAIN;
listen 80;
listen [::]:80;
return 301 https://$host$request_uri;
}
server {
server_name MYDOMAIN;
listen 443 ssl http2;
listen [::]:443 ssl http2;
access_log /var/log/nginx/ausweis.access.log;
error_log /var/log/nginx/ausweis.error.log warn;
# make sure everything under / is in a sub-folder
root /srv/http/my-domain/frontend/;
add_header Permissions-Policy "interest-cohort=()";
add_header Cache-Control must-revalidate;
expires 300;
location /data/ {
alias /srv/http/my-domain/backend_data/;
try_files $uri =404; # disable index, prevent attacks on finding a valid slug
expires 30;
}
location /static/ {
alias /srv/http/my-domain/backend_static/;
try_files $uri =404; # disable index
access_log off;
}
location / {
try_files $uri $uri/ @app_server;
}
location @app_server {
proxy_pass http://ausweis;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_buffering off;
}
ssl_certificate fullchain.pem; # managed by Certbot
ssl_certificate_key privkey.pem; # managed by Certbot
}

View File

@@ -0,0 +1,50 @@
upstream ausweis { server 127.0.0.1:8099; }
server {
server_name MYDOMAIN;
listen 80;
listen [::]:80;
return 301 https://$host$request_uri;
}
server {
server_name MYDOMAIN;
listen 443 ssl http2;
listen [::]:443 ssl http2;
access_log /var/log/nginx/ausweis.access.log;
error_log /var/log/nginx/ausweis.error.log warn;
# make sure everything under / is in a sub-folder
root /srv/http/my-domain/root/;
add_header Permissions-Policy "interest-cohort=()";
add_header Cache-Control must-revalidate;
expires 300;
location /frontend/data/ {
alias /srv/http/my-domain/backend_data/;
try_files $uri =404; # disable index, prevent attacks on finding a valid slug
expires 30;
}
location /frontend {
alias /srv/http/my-domain/frontend/;
access_log off;
}
location /backend/static/ {
alias /srv/http/my-domain/backend_static/;
try_files $uri =404; # disable index
access_log off;
}
location /backend/ {
proxy_pass http://ausweis;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_buffering off;
}
ssl_certificate fullchain.pem; # managed by Certbot
ssl_certificate_key privkey.pem; # managed by Certbot
}