Compare commits

..

44 Commits

Author SHA1 Message Date
e7b8993446 Bump version to 1.1.0 2021-06-11 02:12:42 +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
3a872bceb2 Enable Docker container to generate its own VAPID credentials 2021-06-11 01:06:59 +02:00
4ee4869f82 Minor fixes & improvements
- Move push subscription handling to Notifier class
- Allow duplicate options in config
- Only save subscription in frontend if sub call was successful
2021-06-11 01:04:44 +02:00
618f00a09a Send notifications N hours after arrival, polling once per hour 2021-06-10 23:14:15 +02:00
b8e704c300 Update frontend to allow requesting and receiving notifications
Includes requesting permissions and adding a basic service worker
as well as re-working and restructuring a bunch of the existing logic
for parsing URL parameters, sending data and adding another form field
2021-06-08 00:56:29 +02:00
e521edd62b Add basic webpush backend functionality and endpoints for testing 2021-06-08 00:48:11 +02:00
e7a3a0a673 Update config to search non-repo local files (in /etc) first
In order to allow for secrets in the local config file that should be
nowhere near a public repo
2021-06-08 00:47:00 +02:00
c70a419160 Minor cleanup/fixes 2021-06-07 17:41:04 +02:00
cb6568ea46 Allow giving test result even on retroactive arrivals (Resolves #25) 2021-05-27 16:26:25 +02:00
73241c4116 Split down files and add question about covid-test 2021-04-12 21:22:51 +02:00
4e26bbd6f2 Add CSV Export (resolves #24) 2021-01-13 13:47:13 +01:00
a5acdc53a1 Add automatic entry deletion after a specified amount of time. 2021-01-13 00:32:26 +01:00
09a4bcb201 Docker usually requires sudo 2020-12-15 23:58:15 +01:00
5c58abd977 Unify qotes and file perms in setup.py 2020-12-15 23:48:34 +01:00
2241b3c2d0 Switch from os to pathlib because it's more modern i guess?
(Resolves #19)
2020-12-15 23:40:38 +01:00
ca8f962102 Expose/import more conservatively (Resolves #20) 2020-12-15 23:31:12 +01:00
8c5e359f5b Add customization manual (Resolves #18) 2020-12-15 23:18:59 +01:00
5bb7df157c Mark implausibly long stays and fix very short stays 2020-12-14 19:58:24 +01:00
db5fec8616 Fix not all dashes being replaced with spaces 2020-12-12 00:16:35 +01:00
c05a4fba79 Dataview: Non-ended attendance blocks end in present
(not end of view, which might be in the future)
2020-12-09 17:52:48 +01:00
dda00b4ece Dataview: Hide row if no attendance in given timeframe 2020-12-09 17:49:53 +01:00
82e354f381 Add option to abort signin on error and notification if signin is likely
a double signin. Resolves #17.
2020-12-09 17:19:04 +01:00
76eba15dd6 Make header scroll padding very large so the headers always have enough
room to scroll with the content, even if it overflows a lot
2020-12-09 16:23:30 +01:00
847c79fcfb Fix iOS Safari button look (hopefully) 2020-12-09 16:17:58 +01:00
d368097a8d Bump version to 1.0.0 2020-12-09 16:17:44 +01:00
e8a11ecd95 Clarify README docker instructions 2020-12-07 18:28:22 +01:00
4140799e50 Fix docker container signalling 2020-12-07 17:46:50 +01:00
2b28478323 Bump version to 1.0.0 2020-12-07 16:30:10 +01:00
d35e60a32d Update docker to obtain cert at runtime, not buildtime
since the latter doesn't work
2020-12-07 16:17:42 +01:00
7ba6a2d429 Add docker instructions 2020-12-07 14:22:47 +01:00
79d4af32ac Add working Dockerfile and various configs 2020-12-06 11:55:59 +01:00
7f53165704 Fix copyright symbol 2020-12-05 19:42:55 +01:00
cd87a41940 View: Minor CSS adjustments for visuals 2020-12-05 19:40:54 +01:00
34b1e2ad20 Add door sign generator (resolves #10) 2020-12-05 19:26:47 +01:00
fade25b71d Skip DB search on /data for efficiency on default case 2020-12-05 16:50:35 +01:00
6aed013796 Improve and clean up README 2020-12-05 16:22:53 +01:00
02a07bf76a Move view to view.html for easier routing in nginx 2020-12-03 23:34:14 +01:00
b753feaaeb Change file paths tonew name 2020-12-03 23:19:32 +01:00
d3c0aba10a Add info to 'home page' (resolve #16) 2020-12-03 22:28:14 +01:00
97fa628968 Fix config 2020-12-03 22:28:08 +01:00
b9a599fc97 Reflow Readme 2020-12-03 21:33:23 +01:00
1a23dd6ca0 Add Installation instructions 2020-12-03 21:32:49 +01:00
33 changed files with 1741 additions and 623 deletions

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
FROM alpine:latest
RUN apk add --update --no-cache \
bash python3 py3-pip nginx uwsgi uwsgi-python3 certbot certbot-nginx npm jq
RUN npm install -g web-push
WORKDIR /root/ftracker
COPY ftracker/ ./ftracker/
COPY setup.py .
COPY README.md .
COPY LICENSE.md .
COPY res/config.deploy.ini /etc/ftracker/config.ini
RUN pip3 install wheel
RUN pip3 install .
COPY web/ /var/www/html/ftracker/
COPY res/ ./res/
COPY res/ftracker.nossl.nginx.conf /etc/nginx/conf.d/ftracker.conf
RUN rm /etc/nginx/conf.d/default.conf
RUN mkdir -p /etc/ftracker /var/ftracker \
&& chown -R nginx:nginx /etc/ftracker /var/ftracker
STOPSIGNAL SIGINT
RUN chmod +x ./res/docker-entrypoint.sh
ENTRYPOINT [ "./res/docker-entrypoint.sh" ]

200
INSTALL.md Normal file
View File

@ -0,0 +1,200 @@
How to install and set up an `ftracker` instance
================================================
## Installation
There are 2 methods: Docker and Manual
Docker is usually easier and faster but might not suit your needs.
### Method A: Docker
Pull the container from docker hub using
```bash
sudo docker pull fasttube/ftracker
```
OR build the container locally with
```bash
sudo docker build . -t fasttube/ftracker
```
Then, if you want the container to also handle SSL so it can run standalone you
need to pass it a domain and email so it can obtain a certificate from `Let's
encrypt`. Use the first path in the `-v` option to point to your config file
(see below for customization options):
```bash
sudo docker run \
-it --rm \
--name ftracker \
-e DOMAIN=example.com \
-e LE_EMAIL=admin@example.com \
-p 80:80 \
-p 443:443 \
-v /your/full/path/to/config.ini:/etc/ftracker/config.ini \
fasttube/ftracker
```
Otherwise you can run it without SSL (maybe behind your own web+ssl server)
using just
```bash
sudo docker run \
-it --rm \
--name ftracker \
-p 80:80 \
-v /your/full/path/to/config.ini:/etc/ftracker/config.ini \
fasttube/ftracker
```
If those work in the foreground and everything looks okay, you can start them
without `-it --rm` and with `-d` instead to run them in the background. Keep in
mind that it can take around 10 seconds to fully start.
To stop/start/uninstall the container afterwards, run:
```bash
sudo docker stop ftracker # might take a few seconds
sudo docker start ftracker # continue running
sudo docker rm -f ftracker # uninstall
```
### Method B: Manual
#### 1. FTracker Backend
Install backend system wide:
```bash
# clone, cd into repo
sudo -H pip install . # Use -e if you want to hack on the backend while installed.
```
#### 2. WSGI Server + Service file
You need a WSGI Middleware (using `Flask`'s included `werkzeug` is discouraged
for production environments). I recommend `uwsgi` since it's flexible, fast and
has `nginx` integration. A sample configuration file as well as service
description files for both `systemd` and `rc` are included in `res/` for you to
adapt (file paths etc.) and install to your system (The `systemd` service file
still untested though, feel free to leave feedback).
#### 3. Webserver
You need a webserver. I recommend `nginx` because it's the industry standard
and fast. A sample config file is included in `res/` for you to adapt (domain,
SSL certs) and install to your system. The configuration should include:
Webroot in `web/` with a fallback to the WSGI handler for the backend.
Enabling SSL (https) and redirecting http to https is strongly encouraged, i
recommend using `Let's Encrypt`'s `certbot` to easily obtain certificates.
#### 4. Start/Restart
Edit `config.ini` to your liking. Restart the backend by restarting the `uwsgi`
service, e.g. `sudo systemctl restart ftracker` or `sudo service ftracker
restart`. see below for customization options.
## Customization
`ftracker` has a couple of ways you can make it your own. Here is a breakdown
of the functionalities.
The configuration file is in the `ini` format and all options are in the
`[global]` section. It should be placed in/mounted at
`/etc/ftracker/config.ini` or passed to the python module as the first
argument.
### Storage
`ftracker` uses `TinyDB` for data storage, which is essentially a `json` file.
For manual installations, you can decide where on your filesystem it should be
using the `db_file` option. We recommend something like `/var/ftracker/db.json`
for which you'd need to create the `/var/ftracker` directory and set its
permissions to your webserver user. The docker container handles this
internally.
### Data access
The 'data view' at `/view` is used to view the attendance data. It is protected
using a hard-configured username/password combo because i'm lazy and we didn't
need anything more fancy. We recommend you choose a safe, unguessable password
to protect the attendance data using the `admin_user` and `admin_pass` config
options. Please note: Location data is very sensitive data and has to be
handled carefully under the GDPR. Make sure all users know what happens to
their data and who has access to it.
### Guidelines
When arriving/signing in, users have to agree to a set of guidelines. Insert
a link to a publicly hosted version of your guideline document in the
`guideline_url` option to allow users to find it easily.
### List of allowed names
Some places might require all users arriving/signing in to be in a list of
pre-approved users (i.e. users that signed a permit). To block all names not in
such a list, provide a CSV file with all approved names (and optionally email
adresses for future features ;)) and enter the file's full location in the
`name_file` option. The recommended location is `/var/ftracker/namelist.csv` or
similar.
If you're using docker, you need to mount this file to the location specified
in `name_file` when `run`ning the container using an argument like `-v
/your/host/path/to/namelist.csv:/var/ftracker/namelist.csv`.
The CSV format is:
```
Jane,Doe,j.doe@example.com
First Name,Last Name,f.lastname@example.com
```
Users have to enter their full name like `Jane Doe` in order to be admitted.
The names are slugified for comparison, meaning case, accents and double spaces
are ignored (i.e.
```
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

@ -1,25 +1,47 @@
# FT Corona Tracker
# FaSTTUBe Corona Tracker
Small webapp to track who was in which room at which time to backtrace potential viral infections.
Small webapp to track who was in which room at which time to backtrace
potential viral infections.
**WORK IN PROGRESS** This project is still under heavy construction and not ready for use in production.
For Ideas, Progress, and Bugs visit
[Issues](https://git.fasttube.de/FaSTTUBe/ftracker/issues).
For Ideas and Progress see [Issues](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/issues).
## Requirements
- Unixoid system (linux, BSD, macOS). Windows might also work.
- `python` 3.6+ (might be `python3` on your system)
- `pip` for python 3+ (might be `pip3` on your system)
## How to run
(Dev setup, prod setup not finished yet)
(Dev setup, for prod deployment see below)
```bash
# clone, cd into repo
pip3 install -e .
pip install -e .
python3 -m ftracker
```
Then, point your browser at <http://localhost:5000/?arrival=42>.
Edit `config.ini` to tune your installation (see
[INSTALL.md #customization](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/INSTALL.md#customization)
for customization options).
Then, point your browser at <http://localhost:5000/>.
## Installation/Deployment
See
[INSTALL.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/INSTALL.md)
## Open Sources
This project uses the `QRCode.js` library (Copyright (C) 2012 davidshimjs)
licensed under the MIT License, see `web/qrcodejs/LICENSE`. Thanks!
## License
Licensed under GNU GPL v3, see [LICENSE.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/LICENSE.md) for details.
FTracker is licensed under the GNU GPL v3 license, see
[LICENSE.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/LICENSE.md)
for details.
Copyright (C) 2020 Oskar/FaSTTUBe

View File

@ -4,10 +4,13 @@
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
db_file = 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
name_file = /root/namensliste.csv
name_file = namensliste.csv
# Username and password for data retrieval
@ -21,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

@ -1 +1 @@
from .core import *
from .core import app

View File

@ -1,4 +1,5 @@
from .core import *
from pathlib import Path
from .core import app
# Start the flask server if run from terminal
if __name__ == "__main__":
@ -7,12 +8,15 @@ if __name__ == "__main__":
def get_root():
return app.send_static_file('index.html')
@app.route('/view')
def get_view():
return app.send_static_file('viewdata.html')
@app.route('/<path:path>')
def get_file(path):
def get_path(path):
fpath = f"{app.static_folder}/{path}"
# Prettier URLs by auto-loading <path>.html
# Our nginx config does this as well
if not Path(fpath).is_file():
return app.send_static_file(path + '.html')
return app.send_static_file(path)
# Just allow everything to avoid the hassle when running locally.

View File

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

View File

@ -1,4 +1,5 @@
import sys, os
import sys
from pathlib import Path
from configparser import ConfigParser
class Config:
@ -8,17 +9,17 @@ class Config:
def findConfigFile():
if len(sys.argv) > 1:
return sys.argv[1]
elif os.path.isfile('config.ini'):
return 'config.ini'
elif os.path.isfile('/etc/ftracker/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")
@ -29,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

@ -1,3 +1,4 @@
import atexit
import json
from datetime import datetime
from slugify import slugify
@ -21,6 +22,27 @@ from flask import Flask, request, redirect
app = Flask(__name__, static_folder='../web')
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()
db.close()
atexit.register(shutdown)
@app.route('/guidelines')
def get_guidelines():
dest = config['guideline_url'] or None
@ -65,6 +87,7 @@ def post_arrival():
now = datetime.utcnow().isoformat() + 'Z'
db.insert({
'name': name,
'tested': data.get('tested') or False,
'room': data['room'],
'arrival': data.get('arrival') or now,
'departure': None
@ -125,7 +148,12 @@ def get_data():
start = request.args.get('start', default = None, type = str)
end = request.args.get('end' , default = None, type = str)
room = request.args.get('room' , default = ".*", type = str)
room = request.args.get('room' , default = None, type = str)
# Skip DB query if no parameters given
# (Which is currently every request)
if not (start or end or room):
return json.dumps(db.all(), indent=SPACES), 200
def is_after(val, iso):
return (val >= iso if val else True ) if iso else True
@ -137,7 +165,66 @@ def get_data():
r = db.search(
(Entry.departure.test(is_after, start)) &
(Entry.arrival.test(is_before, end)) &
(Entry.room.search(room))
(Entry.room.search(room or ".*"))
)
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

42
ftracker/deleter.py Normal file
View File

@ -0,0 +1,42 @@
from threading import Thread, Event
from datetime import datetime, timedelta
from tinydb import Query
ONE_DAY_IN_S = 24 * 60 * 60
class Deleter(Thread):
def delete_old_entries(self):
print('Clearing database of old entries...')
td = timedelta(days=self.days)
threshold = datetime.utcnow() - td
iso = threshold.isoformat() + 'Z'
Entry = Query()
self.db.remove((Entry.arrival < iso))
print('Deleted everything until UTC', iso)
def __init__(self, db, days):
self.db = db
self.days = days
self.delete_old_entries()
Thread.__init__(self, daemon=True)
self.stopped = Event()
self.start()
def run(self):
while not self.stopped.wait(ONE_DAY_IN_S):
self.delete_old_entries()
def stop(self):
self.stopped.set()

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

36
res/config.deploy.ini Normal file
View File

@ -0,0 +1,36 @@
[global]
# Persistent file for storage of times, in .json format.
# 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
name_file =
# Username and password for data retrieval
admin_user = admin
admin_pass = topSecret
# Link to a document with guidelines for entering
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

49
res/docker-entrypoint.sh Normal file
View File

@ -0,0 +1,49 @@
#!/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
/usr/sbin/nginx -t
/usr/sbin/nginx
echo " >>> Checking / Installing SSL certificate <<< "
if [[ ${DOMAIN} ]]
then
echo "Obtaining cert for '${DOMAIN}' ..."
echo "Registering with email '${LE_EMAIL}' ..."
certbot -n \
--nginx \
--keep-until-expiring \
--redirect \
--agree-tos \
--cert-name ${DOMAIN} \
-d ${DOMAIN} \
-m ${LE_EMAIL}
echo "Checked/Installed SSL certificate."
fi
echo " >>> Starting uwsgi <<< "
exec /usr/sbin/uwsgi --ini /root/ftracker/res/ftracker.alpine.uwsgi.ini

View File

@ -0,0 +1,12 @@
[uwsgi]
plugin = python3
module = ftracker:app
socket = /tmp/ftracker.sock
manage-script-name = true
master = true
uid = nginx
gid = nginx
proesses = 1
threads = 1

View File

@ -0,0 +1,12 @@
[uwsgi]
plugin = python3
module = ftracker:app
socket = /tmp/ftracker.sock
manage-script-name = true
master = true
uid = www-data
gid = www-data
proesses = 1
threads = 1

View File

@ -3,14 +3,14 @@ server {
listen 443 ssl;
root /root/ft-corona-tracker/web;
root /root/ftracker/web;
index index.html index.htm;
location / {
# First attempt to serve request as file
# If no such file, show index to allow for client side routing
try_files $uri $uri/ @api;
# If no such file, pass to backend
try_files $uri $uri/ $uri.html @api;
}
location @api {

View File

@ -0,0 +1,24 @@
server {
listen 80 default_server;
root /var/www/html/ftracker;
index index.html index.htm;
location / {
# First attempt to serve request as file
# If no such file, pass to backend
try_files $uri $uri/ $uri.html @api;
}
location @api {
include uwsgi_params;
# Pass it to the uwsgi server
uwsgi_pass unix:///tmp/ftracker.sock;
}
# RIP
add_header X-Clacks-Overhead "GNU Terry Pratchett" always;
}

View File

@ -15,7 +15,7 @@ rcvar="ftracker_enable"
pidfile="/var/run/${name}.pid"
logfile="/var/log/${name}.log"
configfile="/root/ft-corona-tracker/res/ftracker.uwsgi.ini"
configfile="/root/ftracker/res/ftracker.uwsgi.ini"
command="/usr/local/bin/uwsgi";
command_args="--ini ${configfile} --daemonize ${logfile} --pidfile ${pidfile}"

View File

@ -5,7 +5,7 @@ After=syslog.target network.target nginx.service
# Configuration mostly stolen from from uwsgi docs
[Service]
User=www-data
ExecStart=/usr/bin/uwsgi --ini /root/ft-corona-tracker/res/ftracker.uwsgi.ini
ExecStart=/usr/bin/uwsgi --ini /root/ftracker/res/ftracker.uwsgi.ini
RuntimeDirectory=uwsgi
Restart=always
KillSignal=SIGQUIT

View File

@ -3,23 +3,24 @@ import setuptools as st
with open("README.md", "r") as f:
long_description = f.read()
with open('LICENSE.md') as f:
with open("LICENSE.md", "r") as f:
license_text = f.read()
st.setup(
name="ftracker",
version="0.1.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",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://git.fasttube.de/FaSTTUBe/ft-corona-tracker",
url="https://git.fasttube.de/FaSTTUBe/ftracker",
packages=st.find_packages(exclude=['tests', 'docs']),
install_requires=[
"flask",
"tinydb",
"python-slugify",
"pywebpush",
],
license=license_text,
classifiers=[

158
web/QRgen.html Normal file
View File

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html>
<head>
<title>FTracker</title>
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form {
padding: 16px;
max-width: 512px;
margin: auto;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
}
.print {
display: none;
text-align: center;
}
.print * {
display: inline-block;
margin: 42px auto;
}
.print .link {
font-size: 24px;
}
@media print {
.print h1 {
margin-top: 64px;
color: #000;
}
}
</style>
</head>
<body>
<main id="formView">
<h1>FTracker<br>Door Sign Genrator</h1>
<form id="roomform">
<label>
Room Nr/Name:<br>
<input type="text" name="room" id="room" placeholder="123" required>
</label>
<input type="submit" value="Print">
</form>
</main>
<main id="printA" class="print">
<h1 class="title"></h1><br>
<div class="qr"></div><br>
<span class="link"></span><br>
<span>
Made with FTracker<br>
https://git.fasttube.de/FaSTTUBe/ftracker<br>
&copy; 2020 Oskar / FaSTTUBe
</span>
</main>
<main id="printD" class="print">
<h1 class="title"></h1><br>
<div class="qr"></div><br>
<span class="link"></span><br>
<span>
Made with FTracker<br>
https://git.fasttube.de/FaSTTUBe/ftracker<br>
&copy; 2020 Oskar / FaSTTUBe
</span>
</main>
<script src="/qrcodejs/qrcode.min.js"></script>
<script>
var fv = document.getElementById('formView')
var pa = document.getElementById('printA')
var pd = document.getElementById('printD')
var rform = document.getElementById('roomform')
rform.onsubmit = function(e) {
e.preventDefault()
var room = e.srcElement[0].value
writePage(pa, room, 'arrival')
writePage(pd, room, 'departure')
printPage(pa, 'ftracker-arrival-'+room)
printPage(pd, 'ftracker-departure-'+room)
}
function writePage(el, room, type) {
var base = location.href.split('/').slice(0,3).join('/')
var url = base + '/?' + type + '=' + room
var title = el.querySelector('.title')
var qr = el.querySelector('.qr')
var link = el.querySelector('.link')
title.innerHTML =
'Scan here to log ' + type + '<br> in room ' + room
link.innerHTML = url
qr.innerHTML = ''
new QRCode(qr, {
text: url,
width: 320,
height: 320
})
}
function printPage(el, name) {
var t = document.title
document.title = name
fv.style.display = 'none'
el.style.display = 'block'
window.print()
el.style.display = 'none'
fv.style.display = 'block'
document.title = t
}
</script>
</body>
</html>

View File

@ -2,281 +2,78 @@
<html>
<head>
<title>FTracker</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form {
padding: 16px;
}
label {
display: block;
font-size: 16px;
margin-bottom: 16px;
color: #444;
}
label#agreelabel {
height: 32px;
line-height: 32px;
}
label span {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
line-height: normal;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
}
input[type=checkbox] {
transform: translateY(-3px);
float: left;
height: 32px;
width: 32px;
margin-right: 8px;
}
.request {
display: block;
position: fixed;
background: #ddd;
top: 16px;
left: 16px;
width: calc(100% - 32px);
box-shadow: 0 1px 4px 0;
}
input[type=datetime-local] {
width: calc(100% - 24px);
padding: 12px;
font-size: 12px;
background: #fff;
}
</style>
<link href="style.css" rel="stylesheet" type="text/css">
<script>
// 1st script, prepares values needed for writing document
var cbt = {
'arrival': 'I have read and will adhere to the <a href="/guidelines" target="_blank">protection guidelines</a>',
'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) {
alert("Query parameter(s) missing")
return null
}
var qparams = document.location.search.substr(1)
if (qparams == "") return {}
qparams = qparams.split('&')
var qps = {}
for (var qparam of qparams) {
var vals = qparam.split('=')
if (vals.length < 2 || !cbt.hasOwnProperty(vals[0])) {
alert("Invalid query parameter")
return null
}
return {
action: vals[0],
room: vals[1]
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.action + "<br>Room " + qp.room)
document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1')
</script></h1>
<form id="mainform">
<div id="startpage">
This is a web app to track which people
were in the same rooms at which times in order to backtrace
potential viral infections.<br><br>
If you've reached this page that either means your're
testing things or something has gone quite wrong with the\
URL.<br>
In the former case: Yay it works! In the latter you should
probably contact an admin or a dev nearby :(<br><br>
Here are a few links for testing:<br>
<a href="/view">View Data</a>,
<a href="/QRgen">Door Sign Generator</a>,
<a href="/?arrival=42">Test Arrival</a>,
<a href="/?departure=42">Test Departure</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>
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>
</div>
<form id="mainform" style="display: none">
<label>
Full Name:<br>
<input type="text" name="name" id="name" placeholder="John Doe" required>
</label>
<label id="agreelabel">
<input type="checkbox" name="agree" id="agree" required>
<span><script>document.write(cbt[qp.action])</script></span>
</label>
<input type="submit">
</form>
<script>
// Prefill the name field if it was successfully entered before
var savedName = localStorage.getItem('name')
if (savedName)
document.getElementById('name').value = savedName
// 2nd script, server API communication
var name, agreed
var mform = document.getElementById('mainform')
mform.onsubmit = function(e) {
e.preventDefault()
name = e.srcElement[0].value
agreed = e.srcElement[1].checked
sendMainData()
}
function sendMainData() {
// POST JSON. See docs/API.md
var payload = (qp.action == 'arrival') ?
{
'room': qp.room,
'name': name,
'agreetoguidelines': agreed
} :
{
'name': name,
'cleanedworkspace': agreed
}
post("/" + qp.action, payload)
}
function post(url, payload) {
console.log("Sending payload:", payload)
return fetch(url, {
method: "POST",
body: JSON.stringify(payload)
}).then(res => {
handleResponse(res)
})
}
function handleResponse(res) {
console.log("Request complete! response:", res)
if (Math.floor(res.status / 100) == 2) {
// Success
mform = document.getElementById('mainform')
mform.innerHTML = "<h2>Done. Thanks!</h2>"
localStorage.setItem('name', name)
} else if (res.status == 409) {
// Conflict, more data requested
handleRequest(res)
} else {
// Any other generic error
res.text().then(function (text) {
alert(text)
})
}
}
function handleRequestSubmit(e, json) {
e.preventDefault()
var input = e.srcElement[0].value
var iso = new Date(input).toISOString()
// POST JSON. See docs/API.md
var payload = (json.request == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': iso,
'agreetoguidelines': agreed
} :
{
'name': name,
'departure': iso,
'cleanedworkspace': agreed
}
return post("/" + json.request, payload)
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
function handleRequest(res) {
var reqt = {
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
}
mform.innerHTML = "<h2>Processing Request...</h2>"
res.json().then(function (json) {
var aInfo = ''
var minD = ''
if (json.request == 'departure') {
var d = new Date(json.arrival.time)
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
minD = `min="${localISOTimeMinutes(d)}"`
}
var now = localISOTimeMinutes(new Date())
document.body.innerHTML +=
`<div class="request">
<h1>${json.request} missing!</h1>
<form id="reqform">
<label>
${reqt[json.request]}
<input type="datetime-local" id="datetime" ${minD} max="${now}" required>
${aInfo}
if (qp.edittime && qp.edittime == 1)
document.write(editTimeBox)
</script>
<label class="checkbox">
<input type="checkbox" name="agree" id="agree" required>
<span><script>
document.write(qp ? cbt[qp.action] : '')
</script></span>
</label>
<script>
if (qp.action && qp.action == 'arrival')
document.write(testCheckBox)
</script>
<input type="submit">
</form>
</div>`
rform = document.getElementById('reqform')
rform.onsubmit = async function(e) {
await handleRequestSubmit(e, json)
document.querySelector('.request').remove()
setTimeout(sendMainData, 200)
}
})
}
</script>
<script src="main.js"></script>
</body>
</html>

278
web/main.js Normal file
View File

@ -0,0 +1,278 @@
var spage = document.getElementById('startpage')
var mform = document.getElementById('mainform')
if (qp.action) {
spage.style.display = 'none'
mform.style.display = 'block'
}
// Prefill the name field if it was successfully entered before
var savedName = localStorage.getItem('name')
if (savedName && qp)
document.getElementById('name').value = savedName
// 2nd script, server API communication
var name, datetime, agreed, tested
mform.onsubmit = function(e) {
e.preventDefault()
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() {
// POST JSON. See docs/API.md
var payload = (qp.action == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': datetime,
'agreetoguidelines': agreed,
'tested': tested
} :
{
'name': name,
'departure': datetime,
'cleanedworkspace': agreed
}
post("/" + qp.action, payload)
}
function post(url, payload) {
console.log("Sending payload:", payload)
return fetch(url, {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(res => {
handleResponse(res)
})
}
function handleResponse(res) {
console.log("Request complete! response:", res)
if (Math.floor(res.status / 100) == 2) {
// Success
mform = document.getElementById('mainform')
mform.innerHTML = "<h2>Done. Thanks!</h2>"
localStorage.setItem('name', name)
} else if (res.status == 409) {
// Conflict, more data requested
handleRequest(res)
} else {
// Any other generic error
res.text().then(function (text) {
alert(text)
})
}
}
function handleRequestSubmit(e, json) {
e.preventDefault()
var input = e.srcElement[0].value
var iso = new Date(input).toISOString()
if (e.srcElement.length > 1)
tested = e.srcElement[1].checked
// POST JSON. See docs/API.md
var payload = (json.request == 'arrival') ?
{
'room': qp.room,
'name': name,
'arrival': iso,
'agreetoguidelines': agreed,
'tested': tested
} :
{
'name': name,
'departure': iso,
'cleanedworkspace': agreed
}
return post("/" + json.request, payload)
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
function handleRequest(res) {
var reqt = {
'arrival': 'You probably forgot to sign in when you arrived. Please enter your arrival time now:',
'departure': 'You probably forgot to sign out when you left. Please enter your departure time now:'
}
mform.innerHTML = "<h2>Processing Request...</h2>"
res.json().then(function (json) {
var aInfo = ''
var minD = ''
var doubleT = ''
var testCheck = ''
if (json.request == 'departure') {
var d = new Date(json.arrival.time)
var dInfo = d.toString('en-GB').split(' ').slice(0,5).join(' ')
aInfo = `Your last arrival was on <b>${dInfo}</b> in room <b>${json.arrival.room}</b>.`
minD = `min="${localISOTimeMinutes(d)}"`
if (new Date() - d < 3 * 60 * 1000) {
doubleT = '<b style="color:red">Your last sign in was less than 3 minutes ago. You might be accidentally trying to sign in twice. If you don\'t intend to log 2 arrivals within the last 3 minutes, please abort below.</b>'
}
} else if (json.request == 'arrival') {
testCheck = testCheckBox.replace('have', 'had then');
}
var now = localISOTimeMinutes(new Date())
document.body.innerHTML +=
`<div class="request">
<h1>${json.request} missing!</h1>
<form id="reqform">
<label>
${reqt[json.request]}
<input type="datetime-local" ${minD} max="${now}" required>
${aInfo}
</label>
${doubleT}
${testCheck}
<input type="submit">
<input type="button" value="Abort" onclick="document.body.innerHTML='<h1>Aborted</h1><form>Nothing was logged.<br>You can close this tab/window now.</form>'">
</form>
</div>`
var rform = document.getElementById('reqform')
rform.onsubmit = async function(e) {
await handleRequestSubmit(e, json)
document.querySelector('.request').remove()
setTimeout(sendMainData, 200)
}
})
}
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);
});
});
});
}

4
web/qrcodejs/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.DS_Store
.idea
.project

14
web/qrcodejs/LICENSE Normal file
View File

@ -0,0 +1,14 @@
The MIT License (MIT)
---------------------
Copyright (c) 2012 davidshimjs
Permission is hereby granted, free of charge,
to any person obtaining a copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1
web/qrcodejs/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

79
web/style.css Normal file
View File

@ -0,0 +1,79 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #ddd;
font-family: sans-serif;
}
h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
form, #startpage {
padding: 16px;
max-width: 512px;
margin: auto;
}
label {
display: block;
font-size: 16px;
margin-bottom: 16px;
color: #444;
}
label.checkbox {
height: 32px;
line-height: 32px;
}
label span {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
line-height: normal;
}
input {
border: none;
padding: 16px;
margin: 4px 0;
font-size: 16px;
}
input[type=text] {
color: #000;
width: calc(100% - 32px);
}
input[type=submit], input[type=button] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: 100%;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input[type=checkbox] {
transform: translateY(-3px);
float: left;
height: 32px;
width: 32px;
margin-right: 8px;
}
.request {
display: block;
position: fixed;
background: #ddd;
top: 16px;
left: 16px;
width: calc(100% - 32px);
box-shadow: 0 1px 4px 0;
}
input[type=datetime-local] {
width: calc(100% - 24px);
padding: 12px;
font-size: 12px;
background: #fff;
}

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)

121
web/view.css Normal file
View File

@ -0,0 +1,121 @@
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
}
header {
height: calc(38px - 16px);
padding: 8px;
white-space: nowrap;
overflow: hidden;
}
main {
height: calc(100% - 38px);
vertical-align: top;
}
main > section {
height: calc(100% - 32px);
display: inline-block;
vertical-align: top;
}
main > .viewheader {
display: inline-block;
vertical-align: top;
}
main > section.names, #nameheader {
width: 199px;
overflow: hidden;
border-right: 1px solid gray;
font-weight: bold;
text-transform: capitalize;
white-space: nowrap;
}
main > section.times, #timeheader {
width: calc(100% - 200px);
overflow: hidden;
}
.scroll {
height: 100%;
width: 100%;
overflow: scroll;
}
.row, .row #timelabels {
position: relative;
height: 32px;
background-size: 60px 100%;
}
.names .row:nth-child(even) { background: #ddd; }
.names .row:nth-child(odd) { background: #eee; }
.times .row:nth-child(even) {
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
}
.times .row:nth-child(odd) {
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
}
.row span {
height: 18px;
padding: 7px;
display: inline-block;
}
.times span, #timeheader span {
position: absolute;
margin-right: 16px;
}
.times span {
background: #c50e1f;
color: #ddd;
font-weight: bold;
-webkit-text-stroke: .4px #c50e1f;
}
.times span.implausible {
background: linear-gradient(to right, #c50e1f, rgba(197,14,31,0.2) 1000px);
}
.viewheader.row {
height: 30px;
background: #ddd !important;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
}
.viewheader span {
background: none !important;
color: #000 !important;
padding-left: 4px;
}
#credprompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 95%;
max-width: 320px;
margin: auto;
background: #ddd;
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
}
#credprompt h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
#credprompt input {
display: block;
border: none;
margin: 16px auto;
padding: 16px;
font-size: 16px;
}
#credprompt input[type=text],
#credprompt input[type=password] {
color: #000;
width: calc(100% - 64px);
}
#credprompt input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: calc(100% - 32px);
}

36
web/view.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<title>FTracker Data</title>
<meta charset="utf-8">
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="view.css" rel="stylesheet" type="text/css">
</head>
<body>
<header>
Start: <input type="datetime-local" id="start" value="2020-12-01T00:00" onchange="renderData()">
Ende: <input type="datetime-local" id="end" onchange="renderData()">
Raum: <input type="text" id="room" placeholder=".* (regex)" onchange="renderData()">
<input type="button" value="Log Out" style="float:right" onclick="localStorage.removeItem('dataauth'); location.reload()">
<input type="button" value="Export CSV" style="float:right; margin-right: 8px;" onclick="exportCSV()">
</header>
<main>
<div class="row viewheader" id="nameheader">
<span><b>Names</b></span>
</div><!--
--><div class="row viewheader" id="timeheader">
<div id="timelabels" style="padding-right: 256px;"></div>
</div>
<section class="names">
<div id="names" style="padding-bottom: 256px;"></div>
</section><!--
--><section class="times">
<div class="scroll">
<div id="times"></div>
</div>
</section>
</main>
<script src="view.js"></script>
</body>
</html>

278
web/view.js Normal file
View File

@ -0,0 +1,278 @@
var data = null;
var names = document.querySelector('main #names')
var times = document.querySelector('main #times')
function exportCSV() {
if (data == null) {
alert('No data found.')
return
}
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
csv = '"ftracker-export",'
days = []
var tc = new Date(startDate.getTime())
tc.setHours(1,0,0,0)
while (tc < endDate) {
var isodate = tc.toISOString().split('T')[0]
csv += ('"' + isodate + '",')
days.push(isodate)
tc.setDate(tc.getDate() + 1);
}
csv = csv.replace(/,$/, '')
csv += '\n'
for (var [name, list] of Object.entries(data)) {
csv += '"' + name + '"'
for (day of days) {
csv += ',"'
daytexts = []
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
var [arrDay, arrT] = localISOTimeMinutes(arrD).split('T')
var [depDay, depT] = localISOTimeMinutes(depD).split('T')
if ((arrDay == day) && (depDay == day)) {
daytexts.push(arrT + '-' + depT + ' (' + entry.room + ')')
} else if (arrDay == day) {
daytexts.push(arrT + '-... (' + entry.room + ')')
} else if (depDay == day) {
daytexts.push('...-' + depT + ' (' + entry.room + ')')
}
}
csv += daytexts.join('\n')
csv += '"'
}
csv += '\n'
}
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(csv));
element.setAttribute('download', 'ftracker-export.csv');
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function renderData() {
if (data == null) {
alert('No data found.')
return
}
names = document.querySelector('main #names')
times = document.querySelector('main #times')
names.innerHTML = ''
times.innerHTML = ''
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
// Clear everything below hours because that would lead to
// misalignments with the day grid
startDate.setMinutes(0,0,0)
var tc = new Date(startDate.getTime())
var content = ''
while (tc < endDate) {
var h = tc.getHours()
var t = (h == 0) ?
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
h+':00'
var left = ((tc - startDate) / (1000 * 60))
content += '<span style="left:'+left+'px;">'+t+'</span>'
tc.setTime(tc.getTime() + (60*60*1000));
}
var timeheader = document.getElementById('timelabels')
timeheader.innerHTML = content
var viewwidth = ((endDate - startDate) / (1000 * 60))
timeheader.style.width = viewwidth + 'px'
times.style.width = viewwidth + 'px'
var rowCount = 0
for (var [name, list] of Object.entries(data)) {
var row = document.createElement('div')
row.classList.add('row')
var rowHasBlock = false
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
var arrD = new Date(entry.arrival)
var depD = entry.departure ? new Date(entry.departure) : new Date()
if (depD < startDate || arrD > endDate)
continue
rowHasBlock = true
// Minutes since start date / beginning
var arr = (arrD - startDate) / (1000 * 60)
var dep = (depD - startDate) / (1000 * 60)
var dur = dep - arr
var block = document.createElement('span')
block.innerHTML = entry.room
block.style.left = arr + 'px' // 1px/min
block.style.width = Math.max(0,(dur-14)) + 'px' // 1px/min
if (entry.tested)
block.style.background = '#080'
if (dur > 60 * 24)
block.classList.add('implausible')
row.appendChild(block)
}
if (rowHasBlock) {
var vname = name.replace(/-/g, ' ')
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
times.appendChild(row)
rowCount += 12
}
}
//var viewheight = rowCount * 32;
//times.style.height = viewheight + 'px'
var tw = document.querySelector('main .scroll')
tw.scrollLeft = tw.scrollWidth
}
function saveData(rdata) {
data = rdata.reduce((acc, entry) => {
var name = entry.name
delete entry.name
acc[name] = [...acc[name] || [], entry];
return acc;
}, {});
console.log(data)
renderData()
}
function submitCredentials() {
var cp = document.querySelector('#credprompt')
var user = cp.querySelector('#user').value
var pass = cp.querySelector('#pass').value
cp.remove()
var auth = btoa(user + ":" + pass)
localStorage.setItem('dataauth', auth)
loadData()
}
function loadData() {
var auth = localStorage.getItem('dataauth')
if (auth == null) {
var prompt = document.createElement('div')
prompt.id = 'credprompt'
prompt.innerHTML = '<h1>Credentials Required</h1>\
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="submit" onclick="submitCredentials()">'
document.body.appendChild(prompt)
document.querySelector('#credprompt #user').focus()
return // Abort load, wait for submit
}
var headers = new Headers()
headers.append('Authorization', 'Basic ' + auth)
var fetchopts = {
method: 'GET',
headers: headers
}
fetch('/data', fetchopts)
.then(res => {
if (Math.floor(res.status / 100) == 2)
return res.json()
else
localStorage.removeItem('dataauth')
res.text().then(function (text) {
alert(text)
location.reload()
})
})
.then(rdata => saveData(rdata))
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
var now = new Date()
var startDate = new Date()
startDate.setDate(now.getDate() - (4*7))
startDate.setHours(0,0,0,0)
document.querySelector('input#start').value = localISOTimeMinutes(startDate)
document.querySelector('input#end').value = localISOTimeMinutes(now)
var scrollbox = document.querySelector('.scroll')
var timehead = document.querySelector('#timeheader')
var namebox = document.querySelector('section.names')
scrollbox.onscroll = function() {
timehead.scrollLeft = scrollbox.scrollLeft
namebox.scrollTop = scrollbox.scrollTop
}
loadData()

View File

@ -1,322 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>FTracker Data</title>
<meta name="theme-color" content="#c50e1f">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<style>
html, body {
margin: 0;
height: 100%;
font-family: sans-serif;
}
header {
height: calc(38px - 16px);
padding: 8px;
}
main {
height: calc(100% - 38px);
vertical-align: top;
}
main > section {
height: calc(100% - 32px);
display: inline-block;
vertical-align: top;
}
main > .viewheader {
display: inline-block;
vertical-align: top;
}
main > section.names, #nameheader {
width: 127px;
overflow: hidden;
border-right: 1px solid gray;
font-weight: bold;
text-transform: capitalize;
}
main > section.times, #timeheader {
width: calc(100% - 128px);
overflow: hidden;
}
.scroll {
height: 100%;
width: 100%;
overflow: scroll;
}
.row, .row #timelabels {
position: relative;
height: 32px;
background-size: 60px 100%;
}
.names .row:nth-child(even) { background: #ddd; }
.names .row:nth-child(odd) { background: #eee; }
.times .row:nth-child(even) {
background-image: linear-gradient(to right, #bbb 1px, #ddd 1px);
}
.times .row:nth-child(odd) {
background-image: linear-gradient(to right, #bbb 1px, #eee 1px);
}
.row span {
padding: 7px;
display: inline-block;
}
.times span, #timeheader span {
position: absolute;
background: #c50e1f;
color: #ddd;
margin-right: 16px;
}
.viewheader.row {
height: 30px;
background: #ddd !important;
border-top: 1px solid gray;
border-bottom: 1px solid gray;
}
.viewheader span {
background: none !important;
color: #000 !important;
padding-left: 4px;
}
#credprompt {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 95%;
max-width: 320px;
margin: auto;
background: #ddd;
box-shadow: 0 0 0 10000px rgba(0,0,0,.75);
}
#credprompt h1 {
margin: 0;
padding: 16px;
text-transform: uppercase;
color: #eee;
background: #c50e1f;
text-align: center;
}
#credprompt input {
display: block;
border: none;
margin: 16px auto;
padding: 16px;
font-size: 16px;
}
#credprompt input[type=text],
#credprompt input[type=password] {
color: #000;
width: calc(100% - 64px);
}
#credprompt input[type=submit] {
background: #c50e1f;
text-transform: uppercase;
font-weight: bold;
color: #fff;
width: calc(100% - 32px);
}
</style>
</head>
<body>
<header>
Start: <input type="datetime-local" id="start" value="2020-12-01T00:00" onchange="renderData()">
Ende: <input type="datetime-local" id="end" onchange="renderData()">
Raum: <input type="text" id="room" placeholder=".* (regex)" onchange="renderData()">
<input type="button" value="Log Out" style="float:right" onclick="localStorage.removeItem('dataauth'); location.reload()">
</header>
<main>
<div class="row viewheader" id="nameheader">
<span><b>Names</b></span>
</div><!--
--><div class="row viewheader" id="timeheader">
<div id="timelabels" style="padding-right: 32px;"></div>
</div>
<section class="names">
<div id="names" style="padding-bottom: 32px;"></div>
</section><!--
--><section class="times">
<div class="scroll">
<div id="times"></div>
</div>
</section>
</main>
<script>
var data = null;
var names = document.querySelector('main #names')
var times = document.querySelector('main #times')
function renderData() {
if (data == null) {
alert('No data found.')
return
}
names = document.querySelector('main #names')
times = document.querySelector('main #times')
names.innerHTML = ''
times.innerHTML = ''
var startInput = document.querySelector('input#start')
var endInput = document.querySelector('input#end')
var roomInput = document.querySelector('input#room')
var startDate = new Date(startInput.value)
var endDate = new Date(endInput.value)
var roomRE = new RegExp(roomInput.value || '.*')
var tc = new Date(startDate.getTime())
var content = ''
while (tc < endDate) {
var h = tc.getHours()
var t = (h == 0) ?
'<b>'+tc.getDate()+'.'+(tc.getMonth()+1)+'.</b>' :
h+':00'
var left = ((tc - startDate) / (1000 * 60))
content += '<span style="left:'+left+'px;">'+t+'</span>'
tc.setTime(tc.getTime() + (60*60*1000));
}
var timeheader = document.getElementById('timelabels')
timeheader.innerHTML = content
var viewwidth = ((endDate - startDate) / (1000 * 60))
timeheader.style.width = viewwidth + 'px'
times.style.width = viewwidth + 'px'
var rowCount = 0
for (var [name, list] of Object.entries(data)) {
var row = document.createElement('div')
row.classList.add('row')
var rowHasRoom = false
for (entry of list) {
if (entry.room.match(roomRE) == null)
continue
rowHasRoom = true
arrD = new Date(entry.arrival)
depD = entry.departure ? new Date(entry.departure) : endDate
// Minutes since start date / beginning
var arr = (arrD - startDate) / (1000 * 60)
var dep = (depD - startDate) / (1000 * 60)
var dur = dep - arr
var block = document.createElement('span')
block.innerHTML = entry.room
block.style.left = arr + 'px' // 1px/min
block.style.width = (dur-14) + 'px' // 1px/min
row.appendChild(block)
}
if (rowHasRoom) {
var vname = name.replace('-', ' ')
names.innerHTML += '<div class="row"><span>'+vname+'</span></div>'
times.appendChild(row)
rowCount += 12
}
}
//var viewheight = rowCount * 32;
//times.style.height = viewheight + 'px'
var tw = document.querySelector('main .scroll')
tw.scrollLeft = tw.scrollWidth
}
function saveData(rdata) {
data = rdata.reduce((acc, entry) => {
var name = entry.name
delete entry.name
acc[name] = [...acc[name] || [], entry];
return acc;
}, {});
console.log(data)
renderData()
}
function submitCredentials() {
var cp = document.querySelector('#credprompt')
var user = cp.querySelector('#user').value
var pass = cp.querySelector('#pass').value
cp.remove()
var auth = btoa(user + ":" + pass)
localStorage.setItem('dataauth', auth)
loadData()
}
function loadData() {
var auth = localStorage.getItem('dataauth')
if (auth == null) {
var prompt = document.createElement('div')
prompt.id = 'credprompt'
prompt.innerHTML = '<h1>Credentials Required</h1>\
<input type="text" id="user" placeholder="username" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="password" id="pass" placeholder="password" onkeydown="if (event.keyCode == 13) {submitCredentials()}">\
<input type="submit" onclick="submitCredentials()">'
document.body.appendChild(prompt)
document.querySelector('#credprompt #user').focus()
return // Abort load, wait for submit
}
var headers = new Headers()
headers.append('Authorization', 'Basic ' + auth)
var fetchopts = {
method: 'GET',
headers: headers
}
fetch('/data', fetchopts)
.then(res => {
if (Math.floor(res.status / 100) == 2)
return res.json()
else
localStorage.removeItem('dataauth')
res.text().then(function (text) {
alert(text)
location.reload()
})
})
.then(rdata => saveData(rdata))
}
function localISOTimeMinutes(date) {
var tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds
var localISOTime = (new Date(date - tzoffset)).toISOString().slice(0, -1);
return localISOTime.split(':').slice(0,2).join(':')
}
document.querySelector('input#end').value = localISOTimeMinutes(new Date())
var scrollbox = document.querySelector('.scroll')
var timehead = document.querySelector('#timeheader')
var namebox = document.querySelector('section.names')
scrollbox.onscroll = function() {
timehead.scrollLeft = scrollbox.scrollLeft
namebox.scrollTop = scrollbox.scrollTop
}
loadData()
</script>
</body>
</html>