Compare commits
33 Commits
7f53165704
...
1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
e7b8993446
|
|||
|
39a461df56
|
|||
|
711fbfd821
|
|||
|
3a872bceb2
|
|||
|
4ee4869f82
|
|||
|
618f00a09a
|
|||
|
b8e704c300
|
|||
|
e521edd62b
|
|||
|
e7a3a0a673
|
|||
|
c70a419160
|
|||
|
cb6568ea46
|
|||
|
73241c4116
|
|||
| 4e26bbd6f2 | |||
| a5acdc53a1 | |||
| 09a4bcb201 | |||
| 5c58abd977 | |||
| 2241b3c2d0 | |||
| ca8f962102 | |||
| 8c5e359f5b | |||
| 5bb7df157c | |||
| db5fec8616 | |||
| c05a4fba79 | |||
| dda00b4ece | |||
| 82e354f381 | |||
| 76eba15dd6 | |||
| 847c79fcfb | |||
| d368097a8d | |||
| e8a11ecd95 | |||
| 4140799e50 | |||
| 2b28478323 | |||
| d35e60a32d | |||
| 7ba6a2d429 | |||
| 79d4af32ac |
36
Dockerfile
Normal file
36
Dockerfile
Normal 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
200
INSTALL.md
Normal 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.
|
||||||
38
README.md
38
README.md
@ -22,44 +22,16 @@ pip install -e .
|
|||||||
python3 -m ftracker
|
python3 -m ftracker
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `config.ini` to tune your installation.
|
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/>.
|
Then, point your browser at <http://localhost:5000/>.
|
||||||
|
|
||||||
## Installation/Deployment
|
## Installation/Deployment
|
||||||
|
|
||||||
### 1. FTracker Backend
|
See
|
||||||
|
[INSTALL.md](https://git.fasttube.de/FaSTTUBe/ft-corona-tracker/src/branch/master/INSTALL.md)
|
||||||
As above:
|
|
||||||
```bash
|
|
||||||
# clone, cd into repo
|
|
||||||
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. Customization
|
|
||||||
|
|
||||||
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`
|
|
||||||
|
|
||||||
## Open Sources
|
## Open Sources
|
||||||
|
|
||||||
|
|||||||
13
config.ini
13
config.ini
@ -4,6 +4,9 @@
|
|||||||
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
|
# Remove or leave empty for temporary (/tmp/ftracker-db.json) storage
|
||||||
db_file = db.json
|
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)
|
# List of people to be allowed, in .csv format (comma, no delimiters)
|
||||||
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
|
# Col1: First Name(s), Col2: Last Name(s), Col3 (optional): EMail
|
||||||
# Remove or leave empty for no check
|
# Remove or leave empty for no check
|
||||||
@ -21,3 +24,13 @@ guideline_url = https://fasttube.de/wp-content/uploads/2020/12/Cororna-Regeln-St
|
|||||||
|
|
||||||
# JSON indentation for debugging
|
# JSON indentation for debugging
|
||||||
json_indent = 4
|
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
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
from .core import *
|
from .core import app
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
from .core import *
|
from .core import app
|
||||||
|
|
||||||
# Start the flask server if run from terminal
|
# Start the flask server if run from terminal
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@ -14,7 +14,7 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
# Prettier URLs by auto-loading <path>.html
|
# Prettier URLs by auto-loading <path>.html
|
||||||
# Our nginx config does this as well
|
# Our nginx config does this as well
|
||||||
if not os.path.isfile(fpath):
|
if not Path(fpath).is_file():
|
||||||
return app.send_static_file(path + '.html')
|
return app.send_static_file(path + '.html')
|
||||||
|
|
||||||
return app.send_static_file(path)
|
return app.send_static_file(path)
|
||||||
|
|||||||
@ -6,6 +6,6 @@
|
|||||||
|
|
||||||
# Corona time tracker
|
# Corona time tracker
|
||||||
|
|
||||||
VERSION = (0, 0, 1)
|
VERSION = (1, 1, 0)
|
||||||
|
|
||||||
__version__ = '.'.join(map(str, VERSION))
|
__version__ = '.'.join(map(str, VERSION))
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import sys, os
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from configparser import ConfigParser
|
from configparser import ConfigParser
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@ -8,17 +9,17 @@ class Config:
|
|||||||
def findConfigFile():
|
def findConfigFile():
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
return sys.argv[1]
|
return sys.argv[1]
|
||||||
elif os.path.isfile('config.ini'):
|
elif Path('/etc/ftracker/config.ini').is_file():
|
||||||
return 'config.ini'
|
|
||||||
elif os.path.isfile('/etc/ftracker/config.ini'):
|
|
||||||
return '/etc/ftracker/config.ini'
|
return '/etc/ftracker/config.ini'
|
||||||
|
elif Path('config.ini').is_file():
|
||||||
|
return 'config.ini'
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
configfile = findConfigFile()
|
configfile = findConfigFile()
|
||||||
|
|
||||||
if configfile:
|
if configfile:
|
||||||
self.config = ConfigParser()
|
self.config = ConfigParser(strict=False)
|
||||||
self.config.read(configfile)
|
self.config.read(configfile)
|
||||||
else:
|
else:
|
||||||
raise Exception("No config file found")
|
raise Exception("No config file found")
|
||||||
@ -29,3 +30,6 @@ class Config:
|
|||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return self.config['global'].get(key)
|
return self.config['global'].get(key)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return repr(self.config.items('global'))
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import atexit
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
@ -21,6 +22,27 @@ from flask import Flask, request, redirect
|
|||||||
app = Flask(__name__, static_folder='../web')
|
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')
|
@app.route('/guidelines')
|
||||||
def get_guidelines():
|
def get_guidelines():
|
||||||
dest = config['guideline_url'] or None
|
dest = config['guideline_url'] or None
|
||||||
@ -65,6 +87,7 @@ def post_arrival():
|
|||||||
now = datetime.utcnow().isoformat() + 'Z'
|
now = datetime.utcnow().isoformat() + 'Z'
|
||||||
db.insert({
|
db.insert({
|
||||||
'name': name,
|
'name': name,
|
||||||
|
'tested': data.get('tested') or False,
|
||||||
'room': data['room'],
|
'room': data['room'],
|
||||||
'arrival': data.get('arrival') or now,
|
'arrival': data.get('arrival') or now,
|
||||||
'departure': None
|
'departure': None
|
||||||
@ -146,3 +169,62 @@ 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'])
|
||||||
|
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
42
ftracker/deleter.py
Normal 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
97
ftracker/notifier.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
from slugify import slugify
|
||||||
|
from threading import Thread, Event
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
ONE_HOUR_IN_S = 60 * 60
|
||||||
|
|
||||||
|
class Notifier(Thread):
|
||||||
|
|
||||||
|
def subscribe_user(self, data):
|
||||||
|
|
||||||
|
pushsubs = self.db.table('pushsubs')
|
||||||
|
|
||||||
|
name = slugify(data['name'])
|
||||||
|
data['name'] = name
|
||||||
|
|
||||||
|
Entry = Query()
|
||||||
|
pushsubs.upsert(data, Entry.name == name)
|
||||||
|
|
||||||
|
def notify_user(self, arrival):
|
||||||
|
|
||||||
|
pushsubs = self.db.table('pushsubs')
|
||||||
|
|
||||||
|
Entry = Query()
|
||||||
|
ps = pushsubs.search(Entry.name == arrival['name'])
|
||||||
|
if len(ps) == 0:
|
||||||
|
print("User is not subscribed to notifications :(")
|
||||||
|
return "User is not subscribed to notifications :("
|
||||||
|
|
||||||
|
ps = ps[0]
|
||||||
|
|
||||||
|
print("Sending notification", arrival, ps)
|
||||||
|
|
||||||
|
subscription = ps['sub']
|
||||||
|
notification = {
|
||||||
|
'title': "Forgot to sign out?",
|
||||||
|
'body': "You didn't sign out of ftracker yet",
|
||||||
|
'arr': arrival
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription,
|
||||||
|
json.dumps(notification),
|
||||||
|
vapid_private_key = self.vapid_creds['private_key'],
|
||||||
|
vapid_claims = self.vapid_creds['claims']
|
||||||
|
)
|
||||||
|
print("Notification sent")
|
||||||
|
return None
|
||||||
|
except WebPushException as exc:
|
||||||
|
print("Notification failed", exc)
|
||||||
|
return repr(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_logged_in_users(self):
|
||||||
|
|
||||||
|
print("Notifying users that aren't signed out yet...")
|
||||||
|
|
||||||
|
td = timedelta(hours=self.hours)
|
||||||
|
|
||||||
|
threshold = datetime.utcnow() - td
|
||||||
|
|
||||||
|
iso = threshold.isoformat() + 'Z'
|
||||||
|
|
||||||
|
Entry = Query()
|
||||||
|
arrivals = self.db.search(
|
||||||
|
(Entry.arrival < iso) & (Entry.departure == None)
|
||||||
|
)
|
||||||
|
|
||||||
|
for arrival in arrivals:
|
||||||
|
self.notify_user(arrival)
|
||||||
|
|
||||||
|
print("Notified everything until UTC", iso)
|
||||||
|
|
||||||
|
def __init__(self, db, hours, vapid_creds):
|
||||||
|
|
||||||
|
self.db = db
|
||||||
|
self.hours = hours
|
||||||
|
self.vapid_creds = vapid_creds
|
||||||
|
|
||||||
|
self.notify_logged_in_users()
|
||||||
|
|
||||||
|
Thread.__init__(self, daemon=True)
|
||||||
|
self.stopped = Event()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
|
||||||
|
while not self.stopped.wait(ONE_HOUR_IN_S):
|
||||||
|
self.notify_logged_in_users()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stopped.set()
|
||||||
36
res/config.deploy.ini
Normal file
36
res/config.deploy.ini
Normal 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
49
res/docker-entrypoint.sh
Normal 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
|
||||||
12
res/ftracker.alpine.uwsgi.ini
Normal file
12
res/ftracker.alpine.uwsgi.ini
Normal 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
|
||||||
12
res/ftracker.debian.uwsgi.ini
Normal file
12
res/ftracker.debian.uwsgi.ini
Normal 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
|
||||||
@ -1,38 +1,38 @@
|
|||||||
server {
|
server {
|
||||||
server_name ftracker.fasttube.de;
|
server_name ftracker.fasttube.de;
|
||||||
|
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
|
|
||||||
root /root/ftracker/web;
|
root /root/ftracker/web;
|
||||||
|
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
# First attempt to serve request as file
|
# First attempt to serve request as file
|
||||||
# If no such file, show index to allow for client side routing
|
# If no such file, pass to backend
|
||||||
try_files $uri $uri/ $uri.html @api;
|
try_files $uri $uri/ $uri.html @api;
|
||||||
}
|
}
|
||||||
|
|
||||||
location @api {
|
location @api {
|
||||||
include uwsgi_params;
|
include uwsgi_params;
|
||||||
# Pass it to the uwsgi server
|
# Pass it to the uwsgi server
|
||||||
uwsgi_pass unix:///tmp/ftracker.sock;
|
uwsgi_pass unix:///tmp/ftracker.sock;
|
||||||
}
|
}
|
||||||
|
|
||||||
# RIP
|
# RIP
|
||||||
add_header X-Clacks-Overhead "GNU Terry Pratchett" always;
|
add_header X-Clacks-Overhead "GNU Terry Pratchett" always;
|
||||||
|
|
||||||
ssl_certificate /usr/local/etc/letsencrypt/live/ftracker.fasttube.de/fullchain.pem;
|
ssl_certificate /usr/local/etc/letsencrypt/live/ftracker.fasttube.de/fullchain.pem;
|
||||||
ssl_certificate_key /usr/local/etc/letsencrypt/live/ftracker.fasttube.de/privkey.pem;
|
ssl_certificate_key /usr/local/etc/letsencrypt/live/ftracker.fasttube.de/privkey.pem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
server_name ftracker.fasttube.de;
|
server_name ftracker.fasttube.de;
|
||||||
|
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|
||||||
# 308 instead of 301 to prohibit method change on redirect
|
# 308 instead of 301 to prohibit method change on redirect
|
||||||
# (some clients change POST to GET on 301, 308 does not allow that)
|
# (some clients change POST to GET on 301, 308 does not allow that)
|
||||||
return 308 https://$host$request_uri;
|
return 308 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|||||||
24
res/ftracker.nossl.nginx.conf
Normal file
24
res/ftracker.nossl.nginx.conf
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
7
setup.py
7
setup.py
@ -3,23 +3,24 @@ import setuptools as st
|
|||||||
with open("README.md", "r") as f:
|
with open("README.md", "r") as f:
|
||||||
long_description = f.read()
|
long_description = f.read()
|
||||||
|
|
||||||
with open('LICENSE.md') as f:
|
with open("LICENSE.md", "r") as f:
|
||||||
license_text = f.read()
|
license_text = f.read()
|
||||||
|
|
||||||
st.setup(
|
st.setup(
|
||||||
name="ftracker",
|
name="ftracker",
|
||||||
version="0.1.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",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
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']),
|
packages=st.find_packages(exclude=['tests', 'docs']),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"flask",
|
"flask",
|
||||||
"tinydb",
|
"tinydb",
|
||||||
"python-slugify",
|
"python-slugify",
|
||||||
|
"pywebpush",
|
||||||
],
|
],
|
||||||
license=license_text,
|
license=license_text,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
|
|||||||
316
web/index.html
316
web/index.html
@ -2,304 +2,78 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>FTracker</title>
|
<title>FTracker</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
<meta name="theme-color" content="#c50e1f">
|
<meta name="theme-color" content="#c50e1f">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<style>
|
<link href="style.css" rel="stylesheet" type="text/css">
|
||||||
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;
|
|
||||||
}
|
|
||||||
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%;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
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>
|
|
||||||
<script>
|
<script>
|
||||||
// 1st script, prepares values needed for writing document
|
// 1st script, prepares values needed for writing document
|
||||||
var cbt = {
|
var cbt = {
|
||||||
'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 editTimeBox = '<label>Departure Date/Time:<input type="datetime-local" name="datetime" id="datetime" required></label>'
|
||||||
function getParams() {
|
function getParams() {
|
||||||
var h = document.location.href
|
var qparams = document.location.search.substr(1)
|
||||||
var qparam = h.split('?')[1] || null
|
if (qparams == "") return {}
|
||||||
if (qparam == null)
|
qparams = qparams.split('&')
|
||||||
return null
|
var qps = {}
|
||||||
var vals = qparam.split('=')
|
for (var qparam of qparams) {
|
||||||
if (vals.length < 2 || !cbt.hasOwnProperty(vals[0]))
|
var vals = qparam.split('=')
|
||||||
return null
|
qps[vals[0]] = vals[1] || null
|
||||||
return {
|
|
||||||
action: vals[0],
|
|
||||||
room: vals[1]
|
|
||||||
}
|
}
|
||||||
|
// 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()
|
var qp = getParams()
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1><script>
|
<h1><script>
|
||||||
if (qp)
|
document.write(qp.action ? (qp.action + "<br>Room " + qp.room) : 'FTracker<br>V1')
|
||||||
document.write(qp.action + "<br>Room " + qp.room)
|
|
||||||
else
|
|
||||||
document.write('FTracker<br>V1')
|
|
||||||
</script></h1>
|
</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>
|
||||||
|
© 2020 made by <a target="_blank" href="mailto:o.winkels@fasttube.de">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>
|
<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>
|
||||||
</label>
|
</label>
|
||||||
<label id="agreelabel">
|
<script>
|
||||||
|
if (qp.edittime && qp.edittime == 1)
|
||||||
|
document.write(editTimeBox)
|
||||||
|
</script>
|
||||||
|
<label class="checkbox">
|
||||||
<input type="checkbox" name="agree" id="agree" required>
|
<input type="checkbox" name="agree" id="agree" required>
|
||||||
<span><script>
|
<span><script>
|
||||||
document.write(qp ? cbt[qp.action] : '')
|
document.write(qp ? cbt[qp.action] : '')
|
||||||
</script></span>
|
</script></span>
|
||||||
</label>
|
</label>
|
||||||
|
<script>
|
||||||
|
if (qp.action && qp.action == 'arrival')
|
||||||
|
document.write(testCheckBox)
|
||||||
|
</script>
|
||||||
<input type="submit">
|
<input type="submit">
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script src="main.js"></script>
|
||||||
var mform = document.getElementById('mainform')
|
|
||||||
if (qp == null) {
|
|
||||||
mform.innerHTML = '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>\
|
|
||||||
© 2020 made by <a target="_blank" href="mailto:o.winkels@fasttube.de">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>'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, agreed
|
|
||||||
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}
|
|
||||||
</label>
|
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
278
web/main.js
Normal file
278
web/main.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
79
web/style.css
Normal file
79
web/style.css
Normal 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
35
web/sw.js
Normal 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
121
web/view.css
Normal 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);
|
||||||
|
}
|
||||||
301
web/view.html
301
web/view.html
@ -2,123 +2,10 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>FTracker Data</title>
|
<title>FTracker Data</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
<meta name="theme-color" content="#c50e1f">
|
<meta name="theme-color" content="#c50e1f">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<style>
|
<link href="view.css" rel="stylesheet" type="text/css">
|
||||||
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 {
|
|
||||||
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@ -126,16 +13,17 @@
|
|||||||
Ende: <input type="datetime-local" id="end" onchange="renderData()">
|
Ende: <input type="datetime-local" id="end" onchange="renderData()">
|
||||||
Raum: <input type="text" id="room" placeholder=".* (regex)" 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="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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<div class="row viewheader" id="nameheader">
|
<div class="row viewheader" id="nameheader">
|
||||||
<span><b>Names</b></span>
|
<span><b>Names</b></span>
|
||||||
</div><!--
|
</div><!--
|
||||||
--><div class="row viewheader" id="timeheader">
|
--><div class="row viewheader" id="timeheader">
|
||||||
<div id="timelabels" style="padding-right: 32px;"></div>
|
<div id="timelabels" style="padding-right: 256px;"></div>
|
||||||
</div>
|
</div>
|
||||||
<section class="names">
|
<section class="names">
|
||||||
<div id="names" style="padding-bottom: 32px;"></div>
|
<div id="names" style="padding-bottom: 256px;"></div>
|
||||||
</section><!--
|
</section><!--
|
||||||
--><section class="times">
|
--><section class="times">
|
||||||
<div class="scroll">
|
<div class="scroll">
|
||||||
@ -143,183 +31,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script src="view.js"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
278
web/view.js
Normal file
278
web/view.js
Normal 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()
|
||||||
Reference in New Issue
Block a user