Compare commits

..

11 Commits

Author SHA1 Message Date
1982a45392 Holefully solve #27 by voiding html form submit action 2022-03-26 15:06:28 +01:00
3cfe42023b Work around pywebpush issue #130 by copying claims before passing 2021-11-03 12:35:33 +01:00
e043f475da allow manual reset of save push sub data 2021-11-03 10:10:27 +01:00
aef2a1ae60 Remove weird backslash from start page 2021-10-30 20:47:07 +02:00
215e848efd Add way for admins to forcibly sign users out at a specific time by clicking implausible entries 2021-10-30 20:45:45 +02:00
01089aaf12 Fix bug that didn't display 3G conformity in view 2021-10-30 19:47:45 +02:00
9c0aa29bff Convert "tested" checkbox into "3G" box 2021-10-14 13:45:00 +02:00
404a995e4f also fade tested entries 2021-10-11 19:39:07 +02:00
062330bcaa Bump version to 1.1.0 2021-06-12 13:55:43 +02:00
39a461df56 Move VAPID public key config to backend for easier config
Also enables the frontend not asking for notiffication permission if it
doesn't need them.
Should also help if it ever needs to be changed to circumvent cache.
2021-06-11 01:57:32 +02:00
711fbfd821 Update docs 2021-06-11 01:28:56 +02:00
12 changed files with 129 additions and 32 deletions

View File

@ -161,3 +161,40 @@ jänE doé
``` ```
would still work, but `Jane D` wouldn't). 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

@ -26,8 +26,10 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St
json_indent = 4 json_indent = 4
# VAPID credentials for push notifications # 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 # 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 # 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_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
push_sender_info = mailto:it@fasttube.de push_sender_info = mailto:it@fasttube.de
# when to notify users, in hours after arrival # when to notify users, in hours after arrival

View File

@ -6,6 +6,6 @@
# Corona time tracker # Corona time tracker
VERSION = (1, 0, 0) VERSION = (1, 1, 0)
__version__ = '.'.join(map(str, VERSION)) __version__ = '.'.join(map(str, VERSION))

View File

@ -171,6 +171,25 @@ def get_data():
return json.dumps(r, indent=SPACES), 200 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']) @app.route('/pushsubscribe', methods=['POST'])
def post_pushsub(): def post_pushsub():

View File

@ -1,4 +1,5 @@
import json import json
import copy
from slugify import slugify from slugify import slugify
from threading import Thread, Event from threading import Thread, Event
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -33,7 +34,7 @@ class Notifier(Thread):
ps = ps[0] ps = ps[0]
print("Sending notification", arrival, ps) print("Sending notification", arrival, ps, self.vapid_creds)
subscription = ps['sub'] subscription = ps['sub']
notification = { notification = {
@ -41,13 +42,15 @@ class Notifier(Thread):
'body': "You didn't sign out of ftracker yet", 'body': "You didn't sign out of ftracker yet",
'arr': arrival 'arr': arrival
} }
privkey = self.vapid_creds['private_key']
claims = copy.copy(self.vapid_creds['claims'])
try: try:
webpush( webpush(
subscription, subscription,
json.dumps(notification), json.dumps(notification),
vapid_private_key = self.vapid_creds['private_key'], vapid_private_key = privkey,
vapid_claims = self.vapid_creds['claims'] vapid_claims = claims
) )
print("Notification sent") print("Notification sent")
return None return None

View File

@ -26,8 +26,10 @@ guideline_url = https://youtu.be/oHg5SJYRHA0
json_indent = 4 json_indent = 4
# VAPID credentials for push notifications # 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 # 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 # 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_private_key = abcdefghijklm_NOPQRSTUVWXYZ-0123456789
push_sender_info = mailto:admin@example.com push_sender_info = mailto:admin@example.com
# when to notify users, in hours after arrival # when to notify users, in hours after arrival

View File

@ -10,11 +10,10 @@ then
web-push generate-vapid-keys --json > $VAPID_CREDS_FILE web-push generate-vapid-keys --json > $VAPID_CREDS_FILE
echo "Patching public key into frontend ..." echo "Patching keypair into config ..."
PUB_KEY=`cat $VAPID_CREDS_FILE | jq -r .publicKey` PUB_KEY=`cat $VAPID_CREDS_FILE | jq -r .publicKey`
sed -i "s/pushServerPublicKey = '[a-zA-Z0-9_\-]*'/pushServerPublicKey = '${PUB_KEY}'/" /var/www/html/ftracker/main.js echo "pushServerPublicKey = ${PUB_KEY}" >> /var/www/html/ftracker/main.js
echo "Patching private key into backend config ..."
PRIV_KEY=`cat $VAPID_CREDS_FILE | jq -r .privateKey` PRIV_KEY=`cat $VAPID_CREDS_FILE | jq -r .privateKey`
echo "push_private_key = ${PRIV_KEY}" >> /etc/ftracker/config.ini echo "push_private_key = ${PRIV_KEY}" >> /etc/ftracker/config.ini

View File

@ -8,7 +8,7 @@ with open("LICENSE.md", "r") as f:
st.setup( st.setup(
name="ftracker", name="ftracker",
version="1.0.0", version="1.1.0",
author="Oskar @ FaSTTUBe", author="Oskar @ FaSTTUBe",
author_email="o.winkels@fasttube.de", author_email="o.winkels@fasttube.de",
description="Small webapp to track who was in which room at which time to backtrace potential viral infections", description="Small webapp to track who was in which room at which time to backtrace potential viral infections",

View File

@ -12,7 +12,7 @@
'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>', 'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>',
'departure': 'I have cleaned my workspace' '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 testCheckBox = '<label class="checkbox"><input type="checkbox" name="tested" id="tested"><span>I fullfill one of the <a href="https://www.bundesregierung.de/breg-de/aktuelles/bund-laender-beratung-corona-1949606">3G requirements</a></span></label>'
var editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>' var editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>'
function getParams() { function getParams() {
var qparams = document.location.search.substr(1) var qparams = document.location.search.substr(1)
@ -33,27 +33,26 @@
</head> </head>
<body> <body>
<h1><script> <h1><script>
document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1') document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1.1')
</script></h1> </script></h1>
<div id="startpage"> <div id="startpage">
This is a web app to track which people This is a web app to track which people were in the same rooms at
were in the same rooms at which times in order to backtrace which times in order to backtrace potential viral infections.<br><br>
potential viral infections.<br><br> If you've reached this page that either means your're testing
If you've reached this page that either means your're things or something has gone quite wrong with the URL.<br>
testing things or something has gone quite wrong with the\
URL.<br>
In the former case: Yay it works! In the latter you should In the former case: Yay it works! In the latter you should
probably contact an admin or a dev nearby :(<br><br> probably contact an admin or a dev nearby :(<br><br>
Here are a few links for testing:<br> Here are a few links for testing:<br>
<a href="/view">View Data</a>, <a href="/view">View Data</a>,
<a href="/QRgen">Door Sign Generator</a>, <a href="/QRgen">Door Sign Generator</a>,
<a href="/?arrival=42">Test Arrival</a>, <a href="/?arrival=42">Test Arrival</a>,
<a href="/?departure=42">Test Departure</a><br><br> <a href="/?departure=42">Test Departure</a>,
<a href="javascript:localStorage.removeItem('pushsub')">Reset Push Subscription</a><br><br>
&copy; 2020 made by <a target="_blank" href="mailto:&#111;&#46;&#119;&#105;&#110;&#107;&#101;&#108;&#115;&#64;&#102;&#97;&#115;&#116;&#116;&#117;&#98;&#101;&#46;&#100;&#101;">Oskar</a> &copy; 2020 made by <a target="_blank" href="mailto:&#111;&#46;&#119;&#105;&#110;&#107;&#101;&#108;&#115;&#64;&#102;&#97;&#115;&#116;&#116;&#117;&#98;&#101;&#46;&#100;&#101;">Oskar</a>
for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br> for <a target="_blank" href="//fasttube.de">FaSTTUBe</a>.<br>
For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/ftracker">git repo</a> For source code & licensing see <a href="//git.fasttube.de/FaSTTUBe/ftracker">git repo</a>
</div> </div>
<form id="mainform" style="display: none"> <form id="mainform" action="javascript:void(0);" style="display: none">
<label> <label>
Full Name:<br> Full Name:<br>
<input type="text" name="name" id="name" placeholder="John Doe" required> <input type="text" name="name" id="name" placeholder="John Doe" required>

View File

@ -1,5 +1,3 @@
var pushServerPublicKey = 'BBwBPYxhogHLU3B1FpxfQNzO3q7qZpmD1n1KaaL8WJbcVmJSHhi1uB-VmvsVjjUHWYCeqKyLT7w-1LBfpIcbbcg'
var spage = document.getElementById('startpage') var spage = document.getElementById('startpage')
var mform = document.getElementById('mainform') var mform = document.getElementById('mainform')
@ -8,10 +6,17 @@ if (qp.action) {
mform.style.display = 'block' mform.style.display = 'block'
} }
// Prefill the name field if it was successfully entered before // Get the name field if it was successfully entered before
var savedName = localStorage.getItem('name') var savedName = localStorage.getItem('name')
if (savedName && qp) if (qp && qp.name) {
// Forced Admin checkout - prefill qp name and auto-agree
document.getElementById('name').value = qp.name.replace(/-/g, ' ').toUpperCase();
document.getElementById('agree').checked = true
document.getElementById('agree').parentElement.style.display = 'none'
} else if (savedName && qp) {
// Prefill the client's locally saved name
document.getElementById('name').value = savedName document.getElementById('name').value = savedName
}
// 2nd script, server API communication // 2nd script, server API communication
var name, datetime, agreed, tested var name, datetime, agreed, tested
@ -45,7 +50,7 @@ function sendMainData() {
'name': name, 'name': name,
'arrival': datetime, 'arrival': datetime,
'agreetoguidelines': agreed, 'agreetoguidelines': agreed,
'tested': tested 'tested': tested // = 3G
} : } :
{ {
'name': name, 'name': name,
@ -106,7 +111,7 @@ function handleRequestSubmit(e, json) {
var iso = new Date(input).toISOString() var iso = new Date(input).toISOString()
if (e.srcElement.length > 1) if (e.srcElement.length > 1)
tested = e.srcElement[1].checked tested = e.srcElement[1].checked // = 3G
// POST JSON. See docs/API.md // POST JSON. See docs/API.md
var payload = (json.request == 'arrival') ? var payload = (json.request == 'arrival') ?
@ -115,7 +120,7 @@ function handleRequestSubmit(e, json) {
'name': name, 'name': name,
'arrival': iso, 'arrival': iso,
'agreetoguidelines': agreed, 'agreetoguidelines': agreed,
'tested': tested 'tested': tested // = 3G
} : } :
{ {
'name': name, 'name': name,
@ -226,6 +231,18 @@ function initPush(name) {
return 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 // Register service worker
navigator.serviceWorker.register("/sw.js").then(function(swRegistration) { navigator.serviceWorker.register("/sw.js").then(function(swRegistration) {
console.log("ServiceWorker registered:", swRegistration) console.log("ServiceWorker registered:", swRegistration)
@ -251,16 +268,18 @@ function initPush(name) {
}).then(function(subscription) { }).then(function(subscription) {
console.log("User is subscribed:", subscription); console.log("User is subscribed:", subscription);
var jsonSub = JSON.stringify({
name: name,
sub: subscription
});
fetch('/pushsubscribe', { fetch('/pushsubscribe', {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ body: jsonSub
name: name,
sub: subscription
})
}).then(function(res) { }).then(function(res) {
if (res.ok) if (res.ok)
localStorage.setItem('pushsub', subscription); localStorage.setItem('pushsub', jsonSub);
}); });
}); });
}); });

View File

@ -67,9 +67,15 @@ main > section.times, #timeheader {
font-weight: bold; font-weight: bold;
-webkit-text-stroke: .4px #c50e1f; -webkit-text-stroke: .4px #c50e1f;
} }
.times span.tested { /* = 3G */
background: rgb(0,136,0);
}
.times span.implausible { .times span.implausible {
background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px); background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px);
} }
.times span.implausible.tested { /* = 3G */
background: linear-gradient(to right, rgb(0,136,0), rgba(0,136,0,0.2) 1000px);
}
.viewheader.row { .viewheader.row {
height: 30px; height: 30px;
background: #ddd !important; background: #ddd !important;

View File

@ -88,6 +88,14 @@ function exportCSV() {
} }
function offerUserCheckout(event) {
var name = event.target.getAttribute('data-name')
var room = event.target.textContent
window.open(`/?departure=${room}&edittime=1&name=${name}`, '_blank').focus();
}
function renderData() { function renderData() {
if (data == null) { if (data == null) {
@ -163,10 +171,13 @@ function renderData() {
block.style.left = arr + 'px' // 1px/min block.style.left = arr + 'px' // 1px/min
block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min
if (entry.tested) if (entry.tested)
block.style.background = '#080' block.classList.add('tested') // = 3G
if (dur > 60 * 24) if (dur > 60 * 24) {
block.classList.add('implausible') block.classList.add('implausible')
block.setAttribute('data-name', name)
block.addEventListener('click', offerUserCheckout);
}
row.appendChild(block) row.appendChild(block)