diff --git a/Dockerfile b/Dockerfile index 5083d2c..d91fae5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/INSTALL.md b/INSTALL.md index b1b6f26..919743f 100644 --- a/INSTALL.md +++ b/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. diff --git a/config.ini b/config.ini index 1ac2ff4..3eee4db 100644 --- a/config.ini +++ b/config.ini @@ -24,3 +24,13 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St # 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 = 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 diff --git a/ftracker/config.py b/ftracker/config.py index fe369c6..4c4cd8b 100644 --- a/ftracker/config.py +++ b/ftracker/config.py @@ -9,17 +9,17 @@ class Config: def findConfigFile(): if len(sys.argv) > 1: return sys.argv[1] - elif Path('config.ini').is_file(): - return 'config.ini' elif Path('/etc/ftracker/config.ini').is_file(): return '/etc/ftracker/config.ini' + elif Path('config.ini').is_file(): + return 'config.ini' else: return None 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')) diff --git a/ftracker/core.py b/ftracker/core.py index e5cc1d0..9a61240 100644 --- a/ftracker/core.py +++ b/ftracker/core.py @@ -26,6 +26,15 @@ 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() @@ -33,6 +42,7 @@ def shutdown(): atexit.register(shutdown) + @app.route('/guidelines') def get_guidelines(): dest = config['guideline_url'] or None @@ -159,3 +169,62 @@ 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(): + + try: + payload = request.data.decode('UTF-8') + data = json.loads(payload) + except ValueError as e: + return 'Error: JSON decode error:\n' + str(e), 400 + + notifier.subscribe_user(data) + + return 'OK', 201 + +@app.route('/testpush/') +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() + arrivals = db.search((Entry.name == name) & (Entry.departure == None)) + + if len(arrivals) == 0: + print("User is not logged in :(") + return "Error: User is not logged in :(", 409 + + error = notifier.notify_user(arrivals[0]) + + if error: + return 'Error: ' + error, 404 + + return 'OK', 201 diff --git a/ftracker/notifier.py b/ftracker/notifier.py new file mode 100644 index 0000000..68ab7aa --- /dev/null +++ b/ftracker/notifier.py @@ -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() diff --git a/res/config.deploy.ini b/res/config.deploy.ini index 546da41..07ba13e 100644 --- a/res/config.deploy.ini +++ b/res/config.deploy.ini @@ -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 diff --git a/res/docker-entrypoint.sh b/res/docker-entrypoint.sh index ef08f87..209b90f 100644 --- a/res/docker-entrypoint.sh +++ b/res/docker-entrypoint.sh @@ -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 diff --git a/setup.py b/setup.py index 86b1180..d2ca234 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ st.setup( "flask", "tinydb", "python-slugify", + "pywebpush", ], license=license_text, classifiers=[ diff --git a/web/index.html b/web/index.html index a3148bc..1d4c56f 100644 --- a/web/index.html +++ b/web/index.html @@ -13,25 +13,27 @@ 'departure': 'I have cleaned my workspace' } var testCheckBox = '' + var editTimeBox = '' function getParams() { - var h = document.location.href - var qparam = h.split('?')[1] || null - if (qparam == null) - return null - var vals = qparam.split('=') - if (vals.length < 2 || !cbt.hasOwnProperty(vals[0])) - return null - return { - action: vals[0], - room: vals[1] + var qparams = document.location.search.substr(1) + if (qparams == "") return {} + qparams = qparams.split('&') + var qps = {} + for (var qparam of qparams) { + var vals = qparam.split('=') + qps[vals[0]] = vals[1] || null } + // Backwards compat + if (qps.arrival) {qps.action = 'arrival'; qps.room = qps.arrival} + if (qps.departure) {qps.action = 'departure'; qps.room = qps.departure} + return qps } var qp = getParams()

This is a web app to track which people @@ -56,6 +58,10 @@ Full Name:
+ diff --git a/web/main.js b/web/main.js index 38d0e83..d97172f 100644 --- a/web/main.js +++ b/web/main.js @@ -1,7 +1,7 @@ var spage = document.getElementById('startpage') var mform = document.getElementById('mainform') -if (qp) { +if (qp.action) { spage.style.display = 'none' mform.style.display = 'block' } @@ -12,18 +12,26 @@ if (savedName && qp) document.getElementById('name').value = savedName // 2nd script, server API communication -var name, agreed, tested +var name, datetime, agreed, tested mform.onsubmit = function(e) { e.preventDefault() - name = e.srcElement[0].value - agreed = e.srcElement[1].checked - if (e.srcElement.length > 2) - tested = e.srcElement[2].checked + var i = 0; + name = e.srcElement[i++].value + if (qp.edittime && qp.edittime == 1) { + var value = e.srcElement[i++].value + datetime = new Date(value).toISOString() + } + agreed = e.srcElement[i++].checked + if (qp.action && qp.action == 'arrival') + tested = e.srcElement[i++].checked + sendMainData() + initPush(name) + } function sendMainData() { @@ -33,11 +41,13 @@ function sendMainData() { { 'room': qp.room, 'name': name, + 'arrival': datetime, 'agreetoguidelines': agreed, 'tested': tested } : { 'name': name, + 'departure': datetime, 'cleanedworkspace': agreed } @@ -159,7 +169,7 @@ function handleRequest(res) {
${doubleT} @@ -179,3 +189,90 @@ function handleRequest(res) { }) } + + +if (qp.edittime && qp.edittime == 1) { + var now = localISOTimeMinutes(new Date()) + document.getElementById('datetime').value = now; + document.getElementById('datetime').max = now; +} + + +/* Push Notifications */ + +function sendNotification() { + + navigator.serviceWorker.ready.then(function(serviceWorker) { + serviceWorker.showNotification("Forgot to sign out?", { + body: "You didn't sign out of ftracker yet", + icon: "/favicon.ico", + actions: [{ + action: "depart", + title: "Sign Out" + }] + }) + }) + +} + +function initPush(name) { + + // Check availability + var supported = "serviceWorker" in navigator && "PushManager" in window + if (!supported) { + console.warn("Push Notifications not supported!") + 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) + }) + + // Request permission + // TODO: Only do this AFTER the first? SUCCESSFUL sign-in + Notification.requestPermission(function(result) { + return (result === 'granted') + }).then(function(consent) { + console.log('Notifications', consent ? 'enabled' : 'denied'); + }) + + // Check if already initialized + if (localStorage.getItem('pushsub')) + return + + // Register push service + navigator.serviceWorker.ready.then(function(serviceWorker) { + serviceWorker.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: pushServerPublicKey + }).then(function(subscription) { + console.log("User is subscribed:", subscription); + + fetch('/pushsubscribe', { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + name: name, + sub: subscription + }) + }).then(function(res) { + if (res.ok) + localStorage.setItem('pushsub', subscription); + }); + }); + }); + +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..613f9f6 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,35 @@ +function receivePushNotification(event) { + + var data = event.data.json(); + + console.log("[Service Worker] Push Received:", data) + + var room = data.arr ? data.arr.room : 'test' + + var options = { + data: `/?departure=${room}&edittime=1`, + body: data.body, + icon: "/favicon.ico", + actions: [{ + action: "depart", + title: "Sign Out" + }] + }; + + event.waitUntil(self.registration.showNotification(data.title, options)) + +} + +self.addEventListener("push", receivePushNotification) + + +function openPushNotification(event) { + + console.log("[Service Worker] Notification click Received.", event.notification.data) + + event.notification.close() + event.waitUntil(clients.openWindow(event.notification.data)) + +} + +self.addEventListener("notificationclick", openPushNotification)