Add push notifications for forgotten departures #26

Merged
o.winkels merged 8 commits from push-notifications into master 2021-06-11 02:07:11 +02:00
12 changed files with 412 additions and 23 deletions

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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'))

View File

@ -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/<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()
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

97
ftracker/notifier.py Normal file
View 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()

View File

@ -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

View File

@ -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

View File

@ -20,6 +20,7 @@ st.setup(
"flask",
"tinydb",
"python-slugify",
"pywebpush",
],
license=license_text,
classifiers=[

View File

@ -13,25 +13,27 @@
'departure': 'I have cleaned my workspace'
}
var testCheckBox = '<label class="checkbox"><input type="checkbox" name="tested" id="tested"><span>I have been tested negative for COVID in the last 24 hours</span></label>'
var editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>'
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()
</script>
</head>
<body>
<h1><script>
document.write(qp ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1')
document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1')
</script></h1>
<div id="startpage">
This is a web app to track which people
@ -56,6 +58,10 @@
Full Name:<br>
<input type="text" name="name" id="name" placeholder="John Doe" required>
</label>
<script>
if (qp.edittime && qp.edittime == 1)
document.write(editTimeBox)
</script>
<label class="checkbox">
<input type="checkbox" name="agree" id="agree" required>
<span><script>
@ -63,7 +69,7 @@
</script></span>
</label>
<script>
if (qp && qp.action == 'arrival')
if (qp.action && qp.action == 'arrival')
document.write(testCheckBox)
</script>
<input type="submit">

View File

@ -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) {
<form id="reqform">
<label>
${reqt[json.request]}
<input type="datetime-local" id="datetime" ${minD} max="${now}" required>
<input type="datetime-local" ${minD} max="${now}" required>
${aInfo}
</label>
${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);
});
});
});
}

35
web/sw.js Normal file
View File

@ -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)