Compare commits
6 Commits
b8e704c300
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
e7b8993446
|
|||
|
39a461df56
|
|||
|
711fbfd821
|
|||
|
3a872bceb2
|
|||
|
4ee4869f82
|
|||
|
618f00a09a
|
@ -1,7 +1,9 @@
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx
|
||||
bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx npm jq
|
||||
|
||||
RUN npm install -g web-push
|
||||
|
||||
|
||||
|
||||
|
||||
37
INSTALL.md
37
INSTALL.md
@ -161,3 +161,40 @@ jänE doé
|
||||
```
|
||||
|
||||
would still work, but `Jane D` wouldn't).
|
||||
|
||||
### Automatic data deletion (GDPR compliance)
|
||||
|
||||
The `delete_after_days` configuration option can be set to a number of days
|
||||
after which attendance records are purged from the database. If it is not set
|
||||
(or empty) automatic deletion is deactivated. Automatic deletion is final and
|
||||
non-recoverable. This option is intended to help make the system fully GDPR
|
||||
compliant by guaranteeing deletion after a certain period. Keep in mind that a
|
||||
legally binding data protection guideline and user consent are still required.
|
||||
|
||||
### User notification on forgotten sign-out
|
||||
|
||||
`ftracker` is capable of notifying users if they forgot to sign-out at the end
|
||||
of a day using modern web push notifications using the VAPID system. To make
|
||||
this work, a few things are needed:
|
||||
|
||||
Firstly, you need an EC-Prime256v1 keypair in base64url encoding. If you're
|
||||
using the Docker container, this is automatically generated for you. If not,
|
||||
the easiest way to create one is to install the `web-push` `npm` package and
|
||||
run it:
|
||||
|
||||
```bash
|
||||
sudo npm install -g web-push
|
||||
web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
The keys then need to be copied into the config options `push_public_key` and
|
||||
`push_private_key` respectively so the backend can handle the rest.
|
||||
|
||||
Next, to be VAPID compliant you have to announce an contact address claim to
|
||||
the push services so they can contact you if anything is going wrong with your
|
||||
notifications. Do this by entering your email address as a `mailto:` link in
|
||||
the `push_sender_info` option, like `mailto:it@fasttube.de`.
|
||||
|
||||
Finally, you can use the `notify_after_hrs` option to specify how long the
|
||||
system should wait after a user's arrival to notify them of their missing
|
||||
departure.
|
||||
|
||||
@ -26,7 +26,11 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St
|
||||
json_indent = 4
|
||||
|
||||
# VAPID credentials for push notifications
|
||||
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# sender info: usually mailto link to responsible party to contact about issues
|
||||
push_public_key = BBwBPYxhogHLU3B1FpxfQNzO3q7qZpmD1n1KaaL8WJbcVmJSHhi1uB-VmvsVjjUHWYCeqKyLT7w-1LBfpIcbbcg
|
||||
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_sender_info = mailto:it@fasttube.de
|
||||
# when to notify users, in hours after arrival
|
||||
notify_after_hrs = 10
|
||||
|
||||
@ -6,6 +6,6 @@
|
||||
|
||||
# Corona time tracker
|
||||
|
||||
VERSION = (1, 0, 0)
|
||||
VERSION = (1, 1, 0)
|
||||
|
||||
__version__ = '.'.join(map(str, VERSION))
|
||||
|
||||
@ -19,7 +19,7 @@ class Config:
|
||||
configfile = findConfigFile()
|
||||
|
||||
if configfile:
|
||||
self.config = ConfigParser()
|
||||
self.config = ConfigParser(strict=False)
|
||||
self.config.read(configfile)
|
||||
else:
|
||||
raise Exception("No config file found")
|
||||
@ -30,3 +30,6 @@ class Config:
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.config['global'].get(key)
|
||||
|
||||
def __repr__(self):
|
||||
return repr(self.config.items('global'))
|
||||
|
||||
@ -22,14 +22,19 @@ from flask import Flask, request, redirect
|
||||
app = Flask(__name__, static_folder='../web')
|
||||
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
pushsubs = db.table('pushsubs')
|
||||
|
||||
|
||||
if config['delete_after_days']:
|
||||
from .deleter import Deleter
|
||||
deleter = Deleter(db, int(config['delete_after_days']))
|
||||
|
||||
|
||||
if config['notify_after_hrs']:
|
||||
from .notifier import Notifier
|
||||
notifier = Notifier(db, int(config['notify_after_hrs']), {
|
||||
'private_key': config['push_private_key'],
|
||||
'claims': {'sub': config['push_sender_info']}
|
||||
})
|
||||
|
||||
|
||||
def shutdown():
|
||||
print('\rReceived stop signal, stopping threads...')
|
||||
deleter.stop()
|
||||
@ -37,6 +42,7 @@ def shutdown():
|
||||
|
||||
atexit.register(shutdown)
|
||||
|
||||
|
||||
@app.route('/guidelines')
|
||||
def get_guidelines():
|
||||
dest = config['guideline_url'] or None
|
||||
@ -165,6 +171,25 @@ def get_data():
|
||||
return json.dumps(r, indent=SPACES), 200
|
||||
|
||||
|
||||
@app.route('/pushinfo')
|
||||
def get_pushinfo():
|
||||
|
||||
if config['notify_after_hrs']:
|
||||
|
||||
r = {
|
||||
'enabled': True,
|
||||
'publickey': config['push_public_key']
|
||||
}
|
||||
|
||||
else:
|
||||
|
||||
r = {
|
||||
'enabled': False,
|
||||
'publickey': None
|
||||
}
|
||||
|
||||
return json.dumps(r, indent=SPACES), 200
|
||||
|
||||
@app.route('/pushsubscribe', methods=['POST'])
|
||||
def post_pushsub():
|
||||
|
||||
@ -174,44 +199,32 @@ def post_pushsub():
|
||||
except ValueError as e:
|
||||
return 'Error: JSON decode error:\n' + str(e), 400
|
||||
|
||||
name = slugify(data['name'])
|
||||
data['name'] = name
|
||||
print(json.dumps(data, indent=SPACES))
|
||||
|
||||
Entry = Query()
|
||||
pushsubs.upsert(data, Entry.name == name)
|
||||
notifier.subscribe_user(data)
|
||||
|
||||
return 'OK', 201
|
||||
|
||||
@app.route('/testpush/<name>')
|
||||
def testpush(name):
|
||||
|
||||
if not 'Authorization' in request.headers:
|
||||
return 'Error: No Authorization', 401, {'WWW-Authenticate': 'Basic'}
|
||||
|
||||
if request.authorization.username != config['admin_user']:
|
||||
return "Wrong username", 403
|
||||
|
||||
if request.authorization.password != config['admin_pass']:
|
||||
return "Wrong password", 403
|
||||
|
||||
Entry = Query()
|
||||
ps = pushsubs.search(Entry.name == name)[0]
|
||||
arrivals = db.search((Entry.name == name) & (Entry.departure == None))
|
||||
|
||||
arr = db.search((Entry.name == name) & (Entry.departure == None))
|
||||
arr = arr if len(arr) else None
|
||||
if len(arrivals) == 0:
|
||||
print("User is not logged in :(")
|
||||
return "Error: User is not logged in :(", 409
|
||||
|
||||
print(ps)
|
||||
error = notifier.notify_user(arrivals[0])
|
||||
|
||||
subscription = ps['sub']
|
||||
notification = {
|
||||
'title': "Forgot to sign out?",
|
||||
'body': "You didn't sign out of ftracker yet",
|
||||
'arr': arr
|
||||
}
|
||||
if error:
|
||||
return 'Error: ' + error, 404
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription,
|
||||
json.dumps(notification, indent=SPACES),
|
||||
vapid_private_key = config['push_private_key'],
|
||||
vapid_claims = {
|
||||
'sub': config['push_sender_info']
|
||||
},
|
||||
verbose=True
|
||||
)
|
||||
return 'OK', 201
|
||||
except WebPushException as exc:
|
||||
print(exc)
|
||||
return 'Error', 500
|
||||
|
||||
97
ftracker/notifier.py
Normal file
97
ftracker/notifier.py
Normal file
@ -0,0 +1,97 @@
|
||||
import json
|
||||
from slugify import slugify
|
||||
from threading import Thread, Event
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
ONE_HOUR_IN_S = 60 * 60
|
||||
|
||||
class Notifier(Thread):
|
||||
|
||||
def subscribe_user(self, data):
|
||||
|
||||
pushsubs = self.db.table('pushsubs')
|
||||
|
||||
name = slugify(data['name'])
|
||||
data['name'] = name
|
||||
|
||||
Entry = Query()
|
||||
pushsubs.upsert(data, Entry.name == name)
|
||||
|
||||
def notify_user(self, arrival):
|
||||
|
||||
pushsubs = self.db.table('pushsubs')
|
||||
|
||||
Entry = Query()
|
||||
ps = pushsubs.search(Entry.name == arrival['name'])
|
||||
if len(ps) == 0:
|
||||
print("User is not subscribed to notifications :(")
|
||||
return "User is not subscribed to notifications :("
|
||||
|
||||
ps = ps[0]
|
||||
|
||||
print("Sending notification", arrival, ps)
|
||||
|
||||
subscription = ps['sub']
|
||||
notification = {
|
||||
'title': "Forgot to sign out?",
|
||||
'body': "You didn't sign out of ftracker yet",
|
||||
'arr': arrival
|
||||
}
|
||||
|
||||
try:
|
||||
webpush(
|
||||
subscription,
|
||||
json.dumps(notification),
|
||||
vapid_private_key = self.vapid_creds['private_key'],
|
||||
vapid_claims = self.vapid_creds['claims']
|
||||
)
|
||||
print("Notification sent")
|
||||
return None
|
||||
except WebPushException as exc:
|
||||
print("Notification failed", exc)
|
||||
return repr(exc)
|
||||
|
||||
|
||||
def notify_logged_in_users(self):
|
||||
|
||||
print("Notifying users that aren't signed out yet...")
|
||||
|
||||
td = timedelta(hours=self.hours)
|
||||
|
||||
threshold = datetime.utcnow() - td
|
||||
|
||||
iso = threshold.isoformat() + 'Z'
|
||||
|
||||
Entry = Query()
|
||||
arrivals = self.db.search(
|
||||
(Entry.arrival < iso) & (Entry.departure == None)
|
||||
)
|
||||
|
||||
for arrival in arrivals:
|
||||
self.notify_user(arrival)
|
||||
|
||||
print("Notified everything until UTC", iso)
|
||||
|
||||
def __init__(self, db, hours, vapid_creds):
|
||||
|
||||
self.db = db
|
||||
self.hours = hours
|
||||
self.vapid_creds = vapid_creds
|
||||
|
||||
self.notify_logged_in_users()
|
||||
|
||||
Thread.__init__(self, daemon=True)
|
||||
self.stopped = Event()
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
|
||||
while not self.stopped.wait(ONE_HOUR_IN_S):
|
||||
self.notify_logged_in_users()
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
@ -4,6 +4,9 @@
|
||||
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
|
||||
db_file = /var/ftracker/db.json
|
||||
|
||||
# Delete all information after X days (e.g. for GDPR compliance)
|
||||
delete_after_days = 28
|
||||
|
||||
# List of people to be allowed, in .csv format (comma, no delimiters)
|
||||
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
|
||||
# Remove or leave empty for no check
|
||||
@ -21,3 +24,13 @@ guideline_url = https://youtu.be/oHg5SJYRHA0
|
||||
|
||||
# JSON indentation for debugging
|
||||
json_indent = 4
|
||||
|
||||
# VAPID credentials for push notifications
|
||||
# private key: base64url encoded public part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# private key: base64url encoded private part of an EC-Prime256v1 keypair. See INSTALL.md
|
||||
# sender info: usually mailto link to responsible party to contact about issues
|
||||
push_public_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
|
||||
push_sender_info = mailto:admin@example.com
|
||||
# when to notify users, in hours after arrival
|
||||
notify_after_hrs = 10
|
||||
|
||||
@ -1,5 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo " >>> Checking / Creating & patching VAPID creds <<< "
|
||||
|
||||
VAPID_CREDS_FILE=/etc/ftracker/vapid-creds.json
|
||||
if [[ ! -f $VAPID_CREDS_FILE ]]
|
||||
then
|
||||
|
||||
echo "Generating keypair ..."
|
||||
|
||||
web-push generate-vapid-keys --json > $VAPID_CREDS_FILE
|
||||
|
||||
echo "Patching keypair into config ..."
|
||||
PUB_KEY=`cat $VAPID_CREDS_FILE | jq -r .publicKey`
|
||||
echo "pushServerPublicKey = ${PUB_KEY}" >> /var/www/html/ftracker/main.js
|
||||
|
||||
PRIV_KEY=`cat $VAPID_CREDS_FILE | jq -r .privateKey`
|
||||
echo "push_private_key = ${PRIV_KEY}" >> /etc/ftracker/config.ini
|
||||
|
||||
fi
|
||||
|
||||
echo " >>> Starting nginx <<< "
|
||||
|
||||
mkdir /run/nginx # needed because of bug in package
|
||||
|
||||
2
setup.py
2
setup.py
@ -8,7 +8,7 @@ with open("LICENSE.md", "r") as f:
|
||||
|
||||
st.setup(
|
||||
name="ftracker",
|
||||
version="1.0.0",
|
||||
version="1.1.0",
|
||||
author="Oskar @ FaSTTUBe",
|
||||
author_email="o.winkels@fasttube.de",
|
||||
description="Small webapp to track who was in which room at which time to backtrace potential viral infections",
|
||||
|
||||
18
web/main.js
18
web/main.js
@ -1,5 +1,3 @@
|
||||
var pushServerPublicKey = 'BBwBPYxhogHLU3B1FpxfQNzO3q7qZpmD1n1KaaL8WJbcVmJSHhi1uB-VmvsVjjUHWYCeqKyLT7w-1LBfpIcbbcg'
|
||||
|
||||
var spage = document.getElementById('startpage')
|
||||
var mform = document.getElementById('mainform')
|
||||
|
||||
@ -226,6 +224,18 @@ function initPush(name) {
|
||||
return
|
||||
}
|
||||
|
||||
fetch('/pushinfo').then(function(res) {
|
||||
if (res.ok)
|
||||
res.json().then(function(push) {
|
||||
if (push.enabled)
|
||||
registerPush(name, push.publickey);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function registerPush(name, pushServerPublicKey) {
|
||||
|
||||
// Register service worker
|
||||
navigator.serviceWorker.register("/sw.js").then(function(swRegistration) {
|
||||
console.log("ServiceWorker registered:", swRegistration)
|
||||
@ -250,7 +260,6 @@ function initPush(name) {
|
||||
applicationServerKey: pushServerPublicKey
|
||||
}).then(function(subscription) {
|
||||
console.log("User is subscribed:", subscription);
|
||||
localStorage.setItem('pushsub', subscription);
|
||||
|
||||
fetch('/pushsubscribe', {
|
||||
method: "POST",
|
||||
@ -259,6 +268,9 @@ function initPush(name) {
|
||||
name: name,
|
||||
sub: subscription
|
||||
})
|
||||
}).then(function(res) {
|
||||
if (res.ok)
|
||||
localStorage.setItem('pushsub', subscription);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user