chore: remove old-api

This commit is contained in:
Paul Bienkowski 2021-11-22 09:53:13 +01:00
parent 4b270877ca
commit 004deb8e60
51 changed files with 0 additions and 13571 deletions

View file

@ -1,31 +0,0 @@
FROM node:15.14-buster
# Install python3, pip3, and make them the default for `python` and `pip` commands
RUN apt-get update && apt-get install -y python3 python3-pip
RUN ln -s $(which python3) /usr/local/bin/python
RUN ln -s $(which pip3) /usr/local/bin/pip
WORKDIR /opt/obs/api
ADD package.json package-lock.json /opt/obs/api/
RUN echo update-notifier=false >> ~/.npmrc
RUN npm ci
ADD scripts /opt/obs/api/scripts/
ADD tools /opt/obs/api/tools/
ADD requirements.txt /opt/obs/api/
RUN pip install -r requirements.txt
RUN pip install -e ./scripts
ADD views /opt/obs/api/views/
ADD src /opt/obs/api/src/
#ADD .migrations.js .
#ADD migrations .
EXPOSE 3000
ENV PORT=3000
ENV DATA_DIR=/data
CMD ["npm", "run", "start"]

View file

@ -1,165 +0,0 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

View file

@ -1,61 +0,0 @@
const Track = require('../src/models/Track');
const { replaceDollarNewlinesHack, detectFormat, buildObsver1 } = require('../src/logic/tracks');
function shouldRebuildBody(track) {
if (!track.trackData || !track.trackData.points.length) {
return false;
}
if (!track.body) {
return true;
}
const body = track.body.trim();
if (!body) {
return true;
}
const actualBody = replaceDollarNewlinesHack(body).trim();
if (body !== actualBody) {
return true;
}
const lineCount = (actualBody.match(/\n/g) || []).length + 1;
const format = detectFormat(body);
if (format === 'invalid') {
return true;
}
// never reconstruct body of version 2
if (format > 1) {
return false;
}
// not enough data in the file
if (lineCount < track.trackData.points.length + 1) {
return true;
}
return false;
}
async function up(next) {
const query = Track.find().populate('trackData');
for await (const track of query) {
const rebuild = shouldRebuildBody(track);
if (rebuild) {
track.body = buildObsver1(track.trackData.points);
}
await track.save();
}
next();
}
async function down(next) {
// nothing to do
next();
}
module.exports = { up, down };

View file

@ -1,15 +0,0 @@
const Track = require('../src/models/Track');
module.exports = {
async up(next) {
for await (const track of Track.find()) {
await track.rebuildTrackDataAndSave();
}
next();
},
async down(next) {
next();
},
};

View file

@ -1,21 +0,0 @@
const Track = require('../src/models/Track');
module.exports = {
async up(next) {
try {
for await (const track of Track.find()) {
track.originalFileName = track.slug + '.csv'
await track.generateOriginalFilePath();
await track.save()
}
next();
} catch(err) {
next(err)
}
},
async down(next) {
next();
},
};

View file

@ -1,25 +0,0 @@
const Track = require('../src/models/Track');
module.exports = {
async up(next) {
try {
for await (const track of Track.find()) {
if (!track.body) {
continue
}
await track.writeToOriginalFile(track.body)
delete track.body;
await track.save()
}
next();
} catch(err) {
next(err)
}
},
async down(next) {
next();
},
};

9419
old-api/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
{
"name": "open-bike-sensor-web-api",
"version": "1.0.0",
"description": "Backend API for the OpenBikeSensor web app",
"main": "app.js",
"scripts": {
"mongo:start": "docker run --name realworld-mongo -p 27017:27017 mongo & sleep 5",
"start": "node src/",
"start:worker": "node src/worker.js",
"dev": "nodemon src/",
"dev:worker": "nodemon -w src/ src/worker.js",
"mongo:stop": "docker stop realworld-mongo && docker rm realworld-mongo",
"autoformat": "eslint --fix .",
"lint": "eslint .",
"test": "jest",
"migrate": "mongoose-data-migrate -c .migrations.js",
"migrate:up": "npm run migrate -- up",
"migrate:down": "npm run migrate -- down"
},
"repository": {
"type": "git",
"url": "git+https://github.com/openbikesensor/obsAPI.git"
},
"license": "LGPLv3",
"dependencies": {
"body-parser": "1.19.0",
"bull": "^3.22.0",
"connect-busboy": "0.0.2",
"cors": "2.8.5",
"csv-parse": "^4.15.1",
"csv-stringify": "^5.6.1",
"ejs": "^3.1.6",
"errorhandler": "1.5.1",
"express": "4.17.1",
"express-jwt": "^6.0.0",
"express-session": "1.17.1",
"jest": "^26.6.3",
"joi": "^17.4.0",
"jsonwebtoken": "8.5.1",
"luxon": "^1.26.0",
"method-override": "3.0.0",
"methods": "1.1.2",
"mongoose": "^6.0.5",
"mongoose-data-migrate": "flashstockinc/mongoose-data-migrate",
"mongoose-unique-validator": "2.0.3",
"morgan": "1.10.0",
"nodemailer": "^6.4.18",
"oauth2orize": "^1.11.0",
"passport": "0.4.1",
"passport-custom": "^1.1.1",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pkce": "^1.0.0-beta2",
"pug": "^3.0.1",
"request": "2.88.2",
"sanitize-filename": "^1.6.3",
"semantic-ui-css": "^2.4.1",
"slug": "^3.5.2",
"turf": "^3.0.14",
"underscore": "^1.12.1"
},
"devDependencies": {
"eslint": "^7.20.0",
"eslint-config-prettier": "^6.15.0",
"eslint-config-standard": "^16.0.2",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.1.5",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-promise": "^4.3.1",
"nodemon": "^2.0.7",
"prettier": "^2.2.1"
},
"jest": {
"modulePathIgnorePatterns": [
"local"
]
},
"prettier": {
"useTabs": false,
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 120
},
"eslintConfig": {
"extends": [
"standard",
"prettier"
],
"plugins": [
"jest",
"prettier"
],
"env": {
"browser": false,
"node": true,
"jest/globals": true
},
"rules": {
"prettier/prettier": "error",
"standard/array-bracket-even-spacing": 0,
"standard/computed-property-even-spacing": 0,
"standard/object-curly-even-spacing": 0
},
"root": true,
"ignorePatterns": [
"postman-examples/**",
"public/**",
"node_modules",
"local"
]
}
}

View file

@ -1,3 +0,0 @@
./scripts
sqlalchemy[asyncio]
asyncpg

View file

@ -1,65 +0,0 @@
function* pairwise(iter) {
let last;
let firstLoop = true;
for (const it of iter) {
if (firstLoop) {
firstLoop = false;
} else {
yield [last, it];
}
last = it;
}
}
function* enumerate(iter) {
let i = 0;
for (const it of iter) {
yield [i, it];
i++;
}
}
const map = (fn) =>
function* (iter) {
for (const [i, it] of enumerate(iter)) {
yield fn(it, i);
}
};
const filter = (fn) =>
function* (iter) {
for (const it of iter) {
if (fn(it)) {
yield it;
}
}
};
const reduce = (fn, init) => (iter) => {
let acc = init;
for (const it of iter) {
acc = fn(acc, it);
}
return acc;
};
const scan = (fn) =>
function* (iter, init) {
let acc = init;
for (const it of iter) {
acc = fn(acc, it);
yield acc;
}
};
const flow = (...reducers) => (input) => reducers.reduce((c, fn) => fn(c), input);
module.exports = {
filter,
map,
enumerate,
pairwise,
flow,
reduce,
scan,
};

View file

@ -1,30 +0,0 @@
const nodemailer = require('nodemailer');
const config = require('../config');
module.exports = sendEmail;
async function sendEmail({ to, subject, html }) {
if (config.mail) {
const from = config.mail.from;
const transporter = nodemailer.createTransport({
host: config.mail.smtp.host,
port: config.mail.smtp.port,
secure: !config.mail.smtp.starttls,
requiretls: config.mail.smtp.starttls,
auth: {
user: config.mail.smtp.username,
pass: config.mail.smtp.password,
},
});
await transporter.sendMail({ from, to, subject, html });
} else {
console.log(`========== E-Mail disabled, see contents below =========
To: ${to}
Subject: ${subject}
${html}
`)
}
}

View file

@ -1,9 +0,0 @@
const wrapRoute = (fn) => async (req, res, next) => {
try {
return await fn(req, res);
} catch (err) {
next(err);
}
};
module.exports = wrapRoute;

View file

@ -1,23 +0,0 @@
module.exports = errorHandler;
function errorHandler(err, req, res, next) {
switch (true) {
case typeof err === 'string': {
// custom application error
const is404 = err.toLowerCase().endsWith('not found');
const statusCode = is404 ? 404 : 400;
return res.status(statusCode).json({ message: err });
}
case err.name === 'ValidationError':
// mongoose validation error
return res.status(400).json({ message: err.message });
case err.name === 'UnauthorizedError':
// jwt authentication error
return res.status(401).json({ message: 'Unauthorized' });
default:
return res.status(500).json({ message: err.message });
}
}

View file

@ -1,19 +0,0 @@
const validateRequest = (schema, property = 'body') => (req, res, next) => {
console.log('validateRequest');
const options = {
abortEarly: false, // include all errors
allowUnknown: true, // ignore unknown props
stripUnknown: true, // remove unknown props
};
const { error, value } = schema.validate(req[property], options);
if (error) {
console.log('error: ', error);
next(`Validation error: ${error.details.map((x) => x.message).join(', ')}`);
} else {
req[property] = value;
next();
}
};
module.exports = validateRequest;

View file

@ -1,146 +0,0 @@
const crypto = require('crypto');
const sendEmail = require('../_helpers/send-email');
const config = require('../config');
const { User } = require('../models');
const baseUrl = config.baseUrl.replace(/\/+$/, '');
module.exports = {
register,
verifyEmail,
forgotPassword,
validateResetToken,
resetPassword,
};
async function register(params) {
const user = await User.findOne({ email: params.email });
if (user) {
// send already registered error in email to prevent account enumeration
return await sendAlreadyRegisteredEmail(params.email);
}
const newUser = new User();
newUser.username = params.username;
newUser.email = params.email;
newUser.setPassword(params.password);
newUser.verificationToken = randomTokenString();
newUser.needsEmailValidation = true;
await newUser.save();
// send email
await sendVerificationEmail(newUser);
}
async function verifyEmail({ token }) {
const account = await User.findOne({ verificationToken: token });
if (!account) {
throw Error('Verification failed');
}
account.needsEmailValidation = false;
account.verificationToken = undefined;
await account.save();
}
async function forgotPassword({ email }) {
const account = await User.findOne({ email });
console.log('forgotPassword', account, email);
// always return ok response to prevent email enumeration
if (!account) return;
// create reset token that expires after 24 hours
account.resetToken = {
token: randomTokenString(),
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
};
await account.save();
console.log('forgotPassword account saved', account);
// send email
await sendPasswordResetEmail(account);
}
async function validateResetToken({ token }) {
const account = await User.findOne({
'resetToken.token': token,
'resetToken.expires': { $gt: Date.now() },
});
if (!account) {
throw Error('Invalid token');
}
}
async function resetPassword({ token, password }) {
const account = await User.findOne({
'resetToken.token': token,
'resetToken.expires': { $gt: Date.now() },
});
if (!account) {
throw Error('Invalid token');
}
// update password and remove reset token
account.setPassword(password);
account.resetToken = undefined;
// Since password recovery happens through email, we can consider this a
// successful verification of the email address.
account.needsEmailValidation = false;
account.verificationToken = undefined;
await account.save();
}
function randomTokenString() {
return crypto.randomBytes(40).toString('hex');
}
async function sendVerificationEmail(account) {
const verifyUrl = `${baseUrl}/verify-email?token=${account.verificationToken}`;
const html = [
'<h4>Verify Email</h4>',
'<p>Thanks for registering!</p>',
'<p>Please click the below link to verify your email address:</p>',
`<p><a href="${verifyUrl}">${verifyUrl}</a></p>`,
].join('\n');
await sendEmail({
to: account.email,
subject: 'Sign-up Verification API - Verify Email',
html,
});
}
async function sendAlreadyRegisteredEmail(email) {
const message = `<p>If you don't know your password please visit the <a href="${baseUrl}/forgot-password">forgot password</a> page.</p>`;
await sendEmail({
to: email,
subject: 'Sign-up Verification API - Email Already Registered',
html: `<h4>Email Already Registered</h4>
<p>Your email <strong>${email}</strong> is already registered.</p>
${message}`,
});
}
async function sendPasswordResetEmail(account) {
const resetUrl = `${baseUrl}/reset-password?token=${account.resetToken.token}`;
const message = `<p>Please click the below link to reset your password, the link will be valid for 1 day:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>`;
await sendEmail({
to: account.email,
subject: 'Sign-up Verification API - Reset Password',
html: `<h4>Reset Password Email</h4>
${message}`,
});
}

View file

@ -1,78 +0,0 @@
const express = require('express');
const router = express.Router();
const Joi = require('joi');
const wrapRoute = require('../_helpers/wrapRoute');
const validateRequest = require('../_middleware/validate-request');
const accountService = require('./account.service');
router.post(
'/register',
validateRequest(
Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.register(req.body, req.get('origin'));
res.json({ message: 'Registration successful, please check your email for verification instructions' });
}),
);
router.post(
'/verify-email',
validateRequest(
Joi.object({
token: Joi.string().required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.verifyEmail(req.body);
res.json({ message: 'Verification successful, you can now login' });
}),
);
router.post(
'/forgot-password',
validateRequest(
Joi.object({
email: Joi.string().email().required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.forgotPassword(req.body, req.get('origin'));
res.json({ message: 'Please check your email for password reset instructions' });
}),
);
router.post(
'/validate-reset-token',
validateRequest(
Joi.object({
token: Joi.string().required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.validateResetToken(req.body);
res.json({ message: 'Token is valid' });
}),
);
router.post(
'/reset-password',
validateRequest(
Joi.object({
token: Joi.string().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.resetPassword(req.body);
res.json({ message: 'Password reset successful, you can now login' });
}),
);
module.exports = router;

View file

@ -1,76 +0,0 @@
const fs = require('fs');
const Joi = require('joi');
const configSchema = Joi.object({
jwtSecret: Joi.string().min(16).max(128).required(),
cookieSecret: Joi.string().min(16).max(128).required(),
imprintUrl: Joi.string(),
privacyPolicyUrl: Joi.string(),
baseUrl: Joi.string().required(),
mainFrontendUrl: Joi.string(), // optional
mail: Joi.alternatives().try(
Joi.object({
from: Joi.string().required(),
smtp: Joi.object({
host: Joi.string().required(),
port: Joi.number().default(465),
starttls: Joi.boolean().default(false),
username: Joi.string().required(),
password: Joi.string().required(),
}).required(),
}),
Joi.boolean().valid(false),
),
mongodb: Joi.object({
url: Joi.string().required(),
debug: Joi.boolean().default(process.env.NODE_ENV !== 'production'),
}).required(),
postgres: Joi.object({
url: Joi.string().required(),
}).required(),
redisUrl: Joi.string().required(),
oAuth2Clients: Joi.array()
.default([])
.items(
Joi.object({
title: Joi.string().required(),
clientId: Joi.string().required(),
validRedirectUris: Joi.array().required().items(Joi.string()),
// Set `refreshTokenExpirySeconds` to null to issue no refresh tokens. Set
// to a number of seconds to issue refresh tokens with that duration. No
// infinite tokens are ever issued, set to big number to simulate that.
refreshTokenExpirySeconds: Joi.number()
.default(null)
.min(1) // 0 would make no sense, use `null` to issue no token
.max(1000 * 24 * 60 * 60), // 1000 days, nearly 3 years
// Set to a scope which cannot be exceeded when requesting client tokens.
// Clients must manually request a scope that is smaller or equal to this
// scope to get a valid response. Scopes are not automatically truncated.
// Leave empty or set to `"*"` for unlimited scopes in this client.
maxScope: Joi.string().required(),
autoAccept: Joi.boolean().optional(),
}),
),
}).required();
const configFiles = [
process.env.CONFIG_FILE,
process.env.NODE_ENV === 'production' ? 'config.prod.json' : 'config.dev.json',
'config.json',
].filter((x) => x && fs.existsSync(x));
if (!configFiles.length) {
throw new Error('No config file found.');
}
module.exports = Joi.attempt(JSON.parse(fs.readFileSync(configFiles[0], 'utf8')), configSchema);

View file

@ -1,13 +0,0 @@
const mongoose = require('mongoose');
const config = require('./config')
mongoose.connect(config.mongodb.url);
mongoose.set('debug', config.mongodb.debug);
require('./models/User');
require('./models/Track');
require('./models/Comment');
require('./passport');
module.exports = mongoose;

View file

@ -1,87 +0,0 @@
const path = require('path');
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const cors = require('cors');
const errorhandler = require('errorhandler');
const passport = require('passport');
const config = require('./config');
require('./passport');
const isProduction = process.env.NODE_ENV === 'production';
// Create global app object
const app = express();
app.use(cors());
// Express configuration
app.set('views', path.join(__dirname, '..', 'views'));
app.set('view engine', 'pug');
// Normal express config defaults
app.use(require('morgan')('dev'));
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: false }));
app.use(require('method-override')());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/semantic-ui', express.static(path.join(__dirname, '..', 'node_modules', 'semantic-ui-css')));
app.use(session({ secret: config.cookieSecret, cookie: { maxAge: 10 * 60 * 1000 }, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
if (!isProduction) {
app.use(errorhandler());
}
require('./db');
require('./models');
app.use(require('./routes'));
/// catch 404 and forward to error handler
app.use(function (req, res, next) {
const err = new Error('Not Found');
err.status = 404;
next(err);
});
/// error handlers
// development error handler
// will print stacktrace
if (!isProduction) {
app.use(function (err, req, res, next) {
console.log(err.stack);
res.status(err.status || 500);
res.json({
errors: {
message: err.message,
error: err,
},
});
});
}
// production error handler
// no stacktraces leaked to user
app.use(function (err, req, res, next) {
res.status(err.status || 500);
res.json({
errors: {
message: err.message,
error: {},
},
});
});
// finally, let's start our server...
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log('Listening on port ' + port);
});

View file

@ -1,357 +0,0 @@
const TEST_ROWS = [
'Date;Time;Latitude;Longitude;Course;Speed;Right;Left;Confirmed;insidePrivacyArea;',
'12.07.2020;09:02:59;0.000000;0.000000;0.000;0.0000;255;255;0;0;',
'12.07.2020;09:02:59;0.000000;0.000000;0.000;0.0000;255;255;0;0;',
'12.07.2020;09:03:00;0.000000;0.000000;0.000;0.0000;255;255;0;0;',
'12.07.2020;09:03:01;48.722205;9.270218;0.000;0.4260;255;255;0;0;',
'12.07.2020;09:03:02;48.722206;9.270219;0.000;0.5741;255;255;0;0;',
'12.07.2020;09:03:03;48.722204;9.270221;0.000;0.5371;255;255;0;0;',
'12.07.2020;09:03:04;48.722198;9.270229;0.000;0.7593;255;255;0;0;',
'12.07.2020;09:03:05;48.722188;9.270241;0.000;0.5556;255;255;0;0;',
'12.07.2020;09:03:06;48.722174;9.270259;0.000;0.4815;255;255;0;0;',
'12.07.2020;09:03:07;48.722158;9.270278;0.000;0.3704;255;255;0;0;',
'12.07.2020;09:03:08;48.722146;9.270293;0.000;0.5741;255;255;0;0;',
'12.07.2020;09:03:09;48.722138;9.270305;0.000;1.2594;255;255;0;0;',
'12.07.2020;09:03:10;48.722129;9.270318;0.000;1.5557;255;255;0;0;',
'12.07.2020;09:03:11;48.722122;9.270329;0.000;1.5372;255;255;0;0;',
'12.07.2020;09:03:12;48.722115;9.270339;0.000;0.4630;255;255;0;0;',
'12.07.2020;09:03:13;48.722107;9.270350;0.000;0.2963;255;255;0;0;',
'12.07.2020;09:03:14;48.722101;9.270357;0.000;0.2963;255;255;0;0;',
'12.07.2020;09:03:15;48.722092;9.270367;0.000;0.8149;255;255;0;0;',
'12.07.2020;09:03:16;48.722084;9.270377;0.000;1.2223;255;255;0;0;',
'12.07.2020;09:03:17;48.722076;9.270385;0.000;0.0926;255;255;0;0;',
'12.07.2020;09:03:18;48.722070;9.270391;0.000;1.4816;255;255;0;0;',
'12.07.2020;09:03:19;48.722070;9.270392;0.000;1.0927;255;255;0;0;',
'12.07.2020;09:03:20;48.722066;9.270395;0.000;1.6668;255;255;0;0;',
'12.07.2020;09:03:21;48.722068;9.270391;0.000;2.0742;255;255;0;0;',
'12.07.2020;09:03:22;48.722064;9.270396;0.000;1.6853;255;255;0;0;',
'12.07.2020;09:03:23;48.722060;9.270401;0.000;1.0927;255;255;0;0;',
'12.07.2020;09:03:24;48.722056;9.270406;0.000;0.9445;255;255;0;0;',
'12.07.2020;09:03:25;48.722052;9.270411;0.000;0.7964;255;255;0;0;',
'12.07.2020;09:03:26;48.722047;9.270416;0.000;0.6482;255;255;0;0;',
'12.07.2020;09:03:27;48.722042;9.270419;0.000;1.0556;255;255;0;0;',
'12.07.2020;09:03:28;48.722031;9.270433;0.000;2.0372;255;255;0;0;',
'12.07.2020;09:03:29;48.722031;9.270432;0.000;2.4261;255;255;0;0;',
'12.07.2020;09:03:30;48.722029;9.270433;0.000;0.8704;255;255;0;0;',
'12.07.2020;09:03:31;48.722029;9.270433;0.000;1.8150;255;255;0;0;',
'12.07.2020;09:03:32;48.722024;9.270439;0.000;1.2223;255;255;0;0;',
'12.07.2020;09:03:33;48.722025;9.270439;0.000;0.3889;255;255;0;0;',
'12.07.2020;09:03:34;48.722022;9.270440;0.000;0.3519;255;255;0;0;',
'12.07.2020;09:03:35;48.722020;9.270445;0.000;0.9445;255;255;0;0;',
'12.07.2020;09:03:36;48.722018;9.270447;0.000;0.9260;255;255;0;0;',
'12.07.2020;09:03:37;48.722020;9.270444;0.000;0.9075;255;255;0;0;',
'12.07.2020;09:03:38;48.722021;9.270443;0.000;1.9261;255;255;0;0;',
'12.07.2020;09:03:39;48.722018;9.270447;0.000;0.3334;255;255;0;0;',
'12.07.2020;09:03:40;48.722020;9.270445;0.000;0.1482;255;255;0;0;',
'12.07.2020;09:03:41;48.722023;9.270440;0.000;1.2594;255;255;0;0;',
'12.07.2020;09:03:42;48.722023;9.270442;0.000;0.5000;255;255;0;0;',
'12.07.2020;09:03:43;48.722025;9.270440;0.000;0.6852;220;255;0;0;',
'12.07.2020;09:03:44;48.722023;9.270441;0.000;0.8519;199;255;0;0;',
'12.07.2020;09:03:45;48.722026;9.270438;0.000;1.4075;255;255;0;0;',
'12.07.2020;09:03:46;48.722029;9.270436;0.000;0.5371;255;255;0;0;',
'12.07.2020;09:03:47;48.722028;9.270435;0.000;0.8334;97;255;0;0;',
'12.07.2020;09:03:48;48.722029;9.270435;0.000;0.3704;255;255;0;0;',
'12.07.2020;09:03:49;48.722029;9.270436;0.000;1.1112;96;255;0;0;',
'12.07.2020;09:03:50;48.722029;9.270435;0.000;1.8890;255;255;0;0;',
'12.07.2020;09:03:51;48.722034;9.270429;0.000;1.0186;255;255;0;0;',
'12.07.2020;09:03:52;48.721942;9.270529;128.450;5.2226;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;79;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;178;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;89;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;156;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;168;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;181;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;176;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;186;0;0;',
'12.07.2020;09:03:53;48.721929;9.270546;128.450;1.3520;255;255;0;0;',
'12.07.2020;09:04:10;48.721896;9.270602;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:04:11;48.721894;9.270609;916.230;0.0926;255;192;0;0;',
'12.07.2020;09:04:12;48.721892;9.270616;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:04:13;48.721890;9.270623;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:04:14;48.721888;9.270629;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:15;48.721886;9.270635;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:16;48.721883;9.270640;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:04:17;48.721881;9.270644;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:04:18;48.721879;9.270649;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:04:19;48.721877;9.270653;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:04:20;48.721876;9.270657;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:21;48.721874;9.270658;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:04:22;48.721873;9.270659;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:04:23;48.721872;9.270661;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:04:24;48.721871;9.270661;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:25;48.721870;9.270660;916.230;0.3334;255;255;0;0;',
'12.07.2020;09:04:26;48.721869;9.270658;916.230;0.5000;255;255;0;0;',
'12.07.2020;09:04:27;48.721866;9.270660;916.230;1.6853;255;255;0;0;',
'12.07.2020;09:04:28;48.721866;9.270659;916.230;0.8704;255;198;0;0;',
'12.07.2020;09:04:29;48.721867;9.270659;916.230;0.5741;255;196;0;0;',
'12.07.2020;09:04:30;48.721867;9.270660;916.230;0.3148;255;196;0;0;',
'12.07.2020;09:04:31;48.721867;9.270659;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:04:32;48.721866;9.270659;916.230;0.0556;255;199;0;0;',
'12.07.2020;09:04:33;48.721867;9.270656;916.230;0.1482;255;199;0;0;',
'12.07.2020;09:04:34;48.721867;9.270654;916.230;0.0370;255;198;0;0;',
'12.07.2020;09:04:35;48.721867;9.270653;916.230;0.1296;255;198;0;0;',
'12.07.2020;09:04:36;48.721867;9.270651;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:04:37;48.721867;9.270650;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:04:38;48.721868;9.270650;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:04:39;48.721868;9.270649;916.230;0.1667;255;201;0;0;',
'12.07.2020;09:04:40;48.721868;9.270647;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:41;48.721869;9.270644;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:04:42;48.721869;9.270641;916.230;0.0185;255;198;0;0;',
'12.07.2020;09:04:43;48.721870;9.270638;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:44;48.721870;9.270635;916.230;0.0370;255;199;0;0;',
'12.07.2020;09:04:45;48.721871;9.270632;916.230;0.1482;255;204;0;0;',
'12.07.2020;09:04:46;48.721871;9.270630;916.230;0.0185;255;201;0;0;',
'12.07.2020;09:04:47;48.721873;9.270630;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:04:48;48.721873;9.270629;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:04:49;48.721874;9.270628;916.230;0.4074;255;255;0;0;',
'12.07.2020;09:04:50;48.721875;9.270627;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:04:51;48.721876;9.270625;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:04:52;48.721877;9.270623;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:04:53;48.721877;9.270622;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:04:54;48.721879;9.270621;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:04:55;48.721881;9.270618;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:04:56;48.721883;9.270615;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:04:57;48.721884;9.270612;916.230;0.2778;255;255;0;0;',
'12.07.2020;09:04:58;48.721885;9.270609;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:04:59;48.721886;9.270606;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:05:00;48.721888;9.270602;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:05:01;48.721889;9.270598;916.230;0.1111;255;191;0;0;',
'12.07.2020;09:05:02;48.721890;9.270595;916.230;0.1482;255;193;0;0;',
'12.07.2020;09:05:03;48.721891;9.270593;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:05:04;48.721891;9.270591;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:05;48.721891;9.270589;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:05:06;48.721891;9.270587;916.230;0.3519;255;199;0;0;',
'12.07.2020;09:05:07;48.721891;9.270586;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:08;48.721891;9.270588;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:09;48.721890;9.270589;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:10;48.721889;9.270589;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:11;48.721888;9.270589;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:05:12;48.721887;9.270589;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:05:13;48.721886;9.270590;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:14;48.721885;9.270591;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:05:15;48.721885;9.270592;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:05:16;48.721885;9.270596;916.230;0.5556;255;255;0;0;',
'12.07.2020;09:05:17;48.721885;9.270598;916.230;0.3519;255;255;0;0;',
'12.07.2020;09:05:18;48.721884;9.270600;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:05:19;48.721882;9.270600;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:20;48.721881;9.270602;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:05:21;48.721879;9.270603;916.230;0.0185;255;206;0;0;',
'12.07.2020;09:05:22;48.721878;9.270605;916.230;0.0556;255;203;0;0;',
'12.07.2020;09:05:23;48.721876;9.270606;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:05:24;48.721874;9.270605;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:05:25;48.721873;9.270605;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:05:26;48.721872;9.270605;916.230;0.1296;255;209;0;0;',
'12.07.2020;09:05:27;48.721870;9.270606;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:05:28;48.721869;9.270608;916.230;0.1111;255;206;0;0;',
'12.07.2020;09:05:29;48.721868;9.270610;916.230;0.3148;255;209;0;0;',
'12.07.2020;09:05:30;48.721867;9.270610;916.230;0.2593;255;208;0;0;',
'12.07.2020;09:05:31;48.721866;9.270611;916.230;0.0556;255;210;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:32;48.721866;9.270612;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:44;48.721855;9.270602;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:44;48.721855;9.270602;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:05:46;48.721854;9.270602;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:46;48.721854;9.270602;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:05:48;48.721852;9.270606;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:05:49;48.721851;9.270611;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:05:50;48.721851;9.270615;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:05:51;48.721851;9.270616;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:05:52;48.721851;9.270617;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:05:53;48.721852;9.270617;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:05:54;48.721853;9.270616;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:05:55;48.721855;9.270613;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:05:56;48.721858;9.270609;916.230;0.6482;255;255;0;0;',
'12.07.2020;09:05:57;48.721860;9.270606;916.230;0.4260;255;255;0;0;',
'12.07.2020;09:05:58;48.721864;9.270601;916.230;0.6297;255;255;0;0;',
'12.07.2020;09:05:59;48.721867;9.270595;916.230;0.4260;255;255;0;0;',
'12.07.2020;09:06:00;48.721872;9.270589;916.230;0.5000;255;255;0;0;',
'12.07.2020;09:06:01;48.721875;9.270584;916.230;0.2593;255;255;0;0;',
'12.07.2020;09:06:02;48.721880;9.270578;916.230;0.5186;255;255;0;0;',
'12.07.2020;09:06:03;48.721883;9.270574;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:06:04;48.721886;9.270570;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:06:05;48.721890;9.270565;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:06:06;48.721893;9.270562;916.230;0.2593;255;255;0;0;',
'12.07.2020;09:06:07;48.721893;9.270560;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:08;48.721894;9.270559;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:09;48.721894;9.270557;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:07;48.721896;9.270556;916.230;0.2778;255;255;0;0;',
'12.07.2020;09:06:08;48.721896;9.270556;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:06:09;48.721895;9.270557;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:10;48.721894;9.270559;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:06:11;48.721892;9.270560;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:06:12;48.721891;9.270561;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:06:13;48.721892;9.270562;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:06:14;48.721891;9.270564;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:15;48.721889;9.270566;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:06:16;48.721888;9.270568;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:17;48.721888;9.270570;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:06:18;48.721888;9.270572;916.230;0.4630;255;255;0;0;',
'12.07.2020;09:06:19;48.721887;9.270573;916.230;0.4815;255;255;0;0;',
'12.07.2020;09:06:20;48.721886;9.270574;916.230;0.3334;255;255;0;0;',
'12.07.2020;09:06:21;48.721885;9.270576;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:06:22;48.721884;9.270579;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:23;48.721882;9.270581;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:24;48.721881;9.270584;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:25;48.721880;9.270589;916.230;0.2963;255;255;0;0;',
'12.07.2020;09:06:26;48.721879;9.270596;916.230;0.3519;255;255;0;0;',
'12.07.2020;09:06:27;48.721878;9.270602;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:06:28;48.721876;9.270601;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:29;48.721874;9.270603;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:30;48.721873;9.270607;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:31;48.721872;9.270614;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:06:32;48.721870;9.270613;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:33;48.721869;9.270614;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:06:34;48.721868;9.270616;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:06:35;48.721867;9.270617;916.230;0.2593;255;255;0;0;',
'12.07.2020;09:06:36;48.721867;9.270618;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:06:37;48.721867;9.270618;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:06:38;48.721867;9.270616;916.230;0.2963;255;255;0;0;',
'12.07.2020;09:06:39;48.721867;9.270613;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:06:40;48.721867;9.270607;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:06:41;48.721866;9.270601;916.230;0.5186;255;255;0;0;',
'12.07.2020;09:06:42;48.721866;9.270593;916.230;0.2963;255;255;0;0;',
'12.07.2020;09:06:43;48.721866;9.270587;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:44;48.721866;9.270581;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:45;48.721866;9.270576;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:46;48.721866;9.270567;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:06:47;48.721866;9.270558;916.230;0.4074;255;255;0;0;',
'12.07.2020;09:06:48;48.721866;9.270550;916.230;0.4260;255;255;0;0;',
'12.07.2020;09:06:49;48.721866;9.270543;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:50;48.721867;9.270537;916.230;0.2778;255;255;0;0;',
'12.07.2020;09:06:51;48.721867;9.270532;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:06:52;48.721867;9.270526;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:06:53;48.721868;9.270522;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:54;48.721868;9.270517;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:06:55;48.721869;9.270512;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:06:56;48.721869;9.270506;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:06:57;48.721869;9.270503;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:06:58;48.721870;9.270500;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:06:59;48.721870;9.270497;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:07:00;48.721871;9.270494;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:07:01;48.721871;9.270493;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:07:02;48.721871;9.270492;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:07:03;48.721872;9.270490;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:07:04;48.721873;9.270489;916.230;0.6667;255;255;0;0;',
'12.07.2020;09:07:05;48.721873;9.270487;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:07:06;48.721873;9.270486;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:07:07;48.721873;9.270486;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:07:08;48.721873;9.270485;916.230;0.1852;255;255;0;0;',
'12.07.2020;09:07:09;48.721873;9.270485;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:07:10;48.721872;9.270485;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:07:11;48.721870;9.270486;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:07:12;48.721869;9.270489;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:07:13;48.721867;9.270492;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:07:14;48.721865;9.270494;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:07:15;48.721863;9.270495;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:07:16;48.721861;9.270497;916.230;0.0000;255;255;0;0;',
'12.07.2020;09:07:17;48.721860;9.270496;916.230;0.4074;255;255;0;0;',
'12.07.2020;09:07:18;48.721859;9.270495;916.230;0.4445;255;255;0;0;',
'12.07.2020;09:07:19;48.721857;9.270496;916.230;0.3889;255;255;0;0;',
'12.07.2020;09:07:20;48.721856;9.270496;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:07:21;48.721854;9.270494;916.230;0.7593;255;255;0;0;',
'12.07.2020;09:07:22;48.721851;9.270496;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:07:23;48.721850;9.270497;916.230;0.1667;255;255;0;0;',
'12.07.2020;09:07:24;48.721848;9.270501;916.230;0.4074;255;255;0;0;',
'12.07.2020;09:07:25;48.721847;9.270504;916.230;0.4074;255;255;0;0;',
'12.07.2020;09:07:26;48.721846;9.270505;916.230;0.2037;255;255;0;0;',
'12.07.2020;09:07:27;48.721844;9.270508;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:07:28;48.721843;9.270507;916.230;0.4630;255;255;0;0;',
'12.07.2020;09:07:29;48.721842;9.270509;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:07:30;48.721841;9.270512;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:07:31;48.721840;9.270515;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:07:32;48.721839;9.270517;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:07:33;48.721838;9.270522;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:07:34;48.721838;9.270527;916.230;0.3889;255;255;0;0;',
'12.07.2020;09:07:35;48.721837;9.270530;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:07:36;48.721836;9.270532;916.230;0.1111;255;255;0;0;',
'12.07.2020;09:07:37;48.721835;9.270536;916.230;0.6112;255;255;0;0;',
'12.07.2020;09:07:38;48.721835;9.270541;916.230;1.1668;255;255;0;0;',
'12.07.2020;09:07:39;48.721835;9.270543;916.230;0.3889;255;255;0;0;',
'12.07.2020;09:07:40;48.721834;9.270545;916.230;0.5000;255;255;0;0;',
'12.07.2020;09:07:41;48.721834;9.270544;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:07:42;48.721834;9.270545;916.230;0.7593;255;255;0;0;',
'12.07.2020;09:07:43;48.721834;9.270545;916.230;0.8890;255;255;0;0;',
'12.07.2020;09:07:44;48.721834;9.270543;916.230;0.4260;255;255;0;0;',
'12.07.2020;09:07:45;48.721834;9.270541;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:07:46;48.721834;9.270540;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:07:47;48.721835;9.270538;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:07:48;48.721835;9.270535;916.230;0.0556;255;255;0;0;',
'12.07.2020;09:07:49;48.721835;9.270534;916.230;0.8890;255;255;0;0;',
'12.07.2020;09:07:50;48.721835;9.270534;916.230;0.5926;255;255;0;0;',
'12.07.2020;09:07:51;48.721835;9.270534;916.230;0.7593;255;255;0;0;',
'12.07.2020;09:07:52;48.721836;9.270533;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:07:53;48.721836;9.270531;916.230;0.0741;255;255;0;0;',
'12.07.2020;09:07:54;48.721836;9.270529;916.230;0.3889;255;255;0;0;',
'12.07.2020;09:07:55;48.721836;9.270530;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:07:56;48.721836;9.270530;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:07:57;48.721837;9.270531;916.230;0.0185;255;255;0;0;',
'12.07.2020;09:07:58;48.721837;9.270530;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:07:59;48.721838;9.270526;916.230;0.3519;255;255;0;0;',
'12.07.2020;09:08:00;48.721838;9.270521;916.230;0.4260;255;255;0;0;',
'12.07.2020;09:08:01;48.721839;9.270522;916.230;0.5556;255;255;0;0;',
'12.07.2020;09:08:02;48.721840;9.270524;916.230;0.3519;255;255;0;0;',
'12.07.2020;09:08:03;48.721842;9.270525;916.230;0.2963;255;255;0;0;',
'12.07.2020;09:08:04;48.721843;9.270525;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:08:05;48.721844;9.270524;916.230;0.2222;255;255;0;0;',
'12.07.2020;09:08:06;48.721846;9.270522;916.230;0.3704;255;255;0;0;',
'12.07.2020;09:08:07;48.721847;9.270519;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:08:08;48.721848;9.270516;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:08:09;48.721849;9.270514;916.230;0.1296;255;255;0;0;',
'12.07.2020;09:08:10;48.721850;9.270512;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:08:11;48.721851;9.270513;916.230;0.3334;255;255;0;0;',
'12.07.2020;09:08:12;48.721851;9.270512;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:08:13;48.721851;9.270512;916.230;0.2593;255;255;0;0;',
'12.07.2020;09:08:14;48.721852;9.270511;916.230;0.0926;255;255;0;0;',
'12.07.2020;09:08:15;48.721853;9.270512;916.230;0.0370;255;255;0;0;',
'12.07.2020;09:08:16;48.721852;9.270515;916.230;0.5371;255;255;0;0;',
'12.07.2020;09:08:17;48.721853;9.270517;916.230;0.1482;255;255;0;0;',
'12.07.2020;09:08:18;48.721854;9.270519;916.230;0.3148;255;255;0;0;',
'12.07.2020;09:08:19;48.721855;9.270520;916.230;0.2408;255;255;0;0;',
'12.07.2020;09:08:20;48.721856;9.270523;916.230;0.3704;255;255;0;0;',
];
const test1 = TEST_ROWS.join('$');
const test2 = `OBSFirmwareVersion=v0.3.999&OBSDataFormat=2&DataPerMeasurement=3&MaximumMeasurementsPerLine=60&OffsetLeft=30&OffsetRight=30&NumberOfDefinedPrivacyAreas=3&PrivacyLevelApplied=AbsolutePrivacy&MaximumValidFlightTimeMicroseconds=18560&DistanceSensorsUsed=HC-SR04/JSN-SR04T&DeviceId=ECEC&OBSUserID=32423432342234
Date;Time;Millis;Comment;Latitude;Longitude;Altitude;Course;Speed;HDOP;Satellites;BatteryLevel;Left;Right;Confirmed;Marked;Invalid;InsidePrivacyArea;Factor;Measurements;Tms1;Lus1;Rus1;Tms2;Lus2;Rus2;Tms3;Lus3;Rus3;Tms4;Lus4;Rus4;Tms5;Lus5;Rus5;Tms6;Lus6;Rus6;Tms7;Lus7;Rus7;Tms8;Lus8;Rus8;Tms9;Lus9;Rus9;Tms10;Lus10;Rus10;Tms11;Lus11;Rus11;Tms12;Lus12;Rus12;Tms13;Lus13;Rus13;Tms14;Lus14;Rus14;Tms15;Lus15;Rus15;Tms16;Lus16;Rus16;Tms17;Lus17;Rus17;Tms18;Lus18;Rus18;Tms19;Lus19;Rus19;Tms20;Lus20;Rus20;Tms21;Lus21;Rus21;Tms22;Lus22;Rus22;Tms23;Lus23;Rus23;Tms24;Lus24;Rus24;Tms25;Lus25;Rus25;Tms26;Lus26;Rus26;Tms27;Lus27;Rus27;Tms28;Lus28;Rus28;Tms29;Lus29;Rus29;Tms30;Lus30;Rus30;Tms31;Lus31;Rus31;Tms32;Lus32;Rus32;Tms33;Lus33;Rus33;Tms34;Lus34;Rus34;Tms35;Lus35;Rus35;Tms36;Lus36;Rus36;Tms37;Lus37;Rus37;Tms38;Lus38;Rus38;Tms39;Lus39;Rus39;Tms40;Lus40;Rus40;Tms41;Lus41;Rus41;Tms42;Lus42;Rus42;Tms43;Lus43;Rus43;Tms44;Lus44;Rus44;Tms45;Lus45;Rus45;Tms46;Lus46;Rus46;Tms47;Lus47;Rus47;Tms48;Lus48;Rus48;Tms49;Lus49;Rus49;Tms50;Lus50;Rus50;Tms51;Lus51;Rus51;Tms52;Lus52;Rus52;Tms53;Lus53;Rus53;Tms54;Lus54;Rus54;Tms55;Lus55;Rus55;Tms56;Lus56;Rus56;Tms57;Lus57;Rus57;Tms58;Lus58;Rus58;Tms59;Lus59;Rus59;Tms60;Lus60;Rus60
18.11.2020;16:05:59;1265034;;48.723224;9.094103;495.3;189.86;3.2;1.01;7;3.74;770;;0;0;58;54;0;6231;;16;;;36;6350;;52;;;72;6263;;87;;;107;6828;;122;;;143;6836;;158;;;178;6936;;193;;;213;7094;;228;;;248;6822;;263;;;284;7019;;299;;;319;6942;;334;;;354;7110;;370;;;390;7203;;405;;;425;7758;;440;;;461;7266;;476;;;496;7499;;511;;;531;7328;;546;;;567;7354;;582;;;602;7397;;617;;;637;;;664;;;684;16615;;708;;;728;9161;;745;;;765;10238;;783;;;802;8525;;818;;;839;7756;;854;;;875;7580;;890;;;910;7926;;925;;;945;7624;;960;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:00;1266041;DEVELOP: GPSMessages: 2587 GPS crc errors: 0;48.723205;9.0941;495.4;189.86;2.87;1.01;7;3.74;1020;;0;0;58;53;0;8012;;27;;;47;7999;;62;;;83;7660;;98;;;118;7698;;133;;;158;1252;;169;;;194;1146;;204;;;229;1173;;239;;;264;1173;;274;;;300;1147;;310;;;335;7943;;352;;;371;8713;;387;;;407;8005;;423;;;443;8021;;458;;;478;;;505;;;525;8111;;541;;;560;8074;;576;;;596;8254;;612;;;632;8514;;647;;;667;8195;;682;;;703;8094;;718;;;738;8123;;754;;;774;8330;;789;;;810;8966;;826;;;846;9066;;862;;;882;10553;;899;;;920;8345;;935;;;955;9219;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:01;1267037;DEVELOP: Mem: 45k Buffer: 4k last write time: 58;48.723197;9.094089;495.7;189.86;2.93;1.01;7;3.74;1090;;0;0;58;53;0;8164;;18;;;39;8184;;53;;;74;16305;;98;;;118;8658;;135;;;155;8198;;170;;;190;8133;;205;;;226;8536;;241;;;261;8676;;276;;;296;8516;;314;;;334;8114;;350;;;370;8294;;385;;;405;8751;;422;;;441;8163;;457;;;478;8062;;493;;;513;8093;;528;;;549;8060;;564;;;584;8085;;599;;;619;8071;;634;;;655;8262;;671;;;690;8746;;707;;;726;9116;;742;;;762;;;789;;;808;8121;;825;;;845;8113;;860;;;881;8129;;896;;;916;8096;;932;;;952;10617;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:02;1268027;;48.723185;9.094076;496.1;189.86;3.02;1.01;7;3.74;980;;0;0;58;55;0;8173;;18;;;37;8535;;53;;;73;8435;;88;;;109;8592;;124;;;144;8012;;159;;;180;8037;;195;;;215;7975;;230;;;250;7970;;265;;;286;7850;;301;;;321;7861;;336;;;356;7826;;371;;;392;8097;;407;;;427;8467;;443;;;463;7763;;478;;;498;7687;;513;;;534;7950;;549;;;569;7806;;584;;;604;8253;;620;;;640;7753;;656;;;676;8188;;692;;;711;7533;;727;;;747;7791;;763;;;783;7460;;798;;;825;9827;;843;;;863;7432;;878;;;904;7646;;919;;;939;7538;;955;;;974;7508;;;;;;;;;;;;;;;;
18.11.2020;16:06:03;1269096;;48.723177;9.094068;496.2;189.86;3;1.01;7;3.74;920;;0;0;58;51;0;7218;;19;;;38;8144;;54;;;74;7463;;89;;;110;7856;;125;;;145;7869;;161;;;181;7422;;196;;;216;7934;;232;;;252;7363;;267;;;293;7297;;307;;;332;8105;;348;;;367;7468;;383;;;403;7213;;418;;;439;7172;;454;;;478;7184;;489;;;514;7312;;528;;;550;7175;;565;;;585;7180;;600;;;620;7013;;635;;;655;7154;;670;;;691;7240;;706;;;726;7075;;741;;;761;7133;;776;;;801;7511;;815;;;836;7639;;851;;;872;8891;;888;;;908;7070;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:04;1270033;;48.723167;9.094056;496.6;189.86;3.19;1.01;7;3.74;870;;0;0;58;53;0;7617;;19;;;39;6812;;55;;;80;1173;;90;;;116;8173;;133;;;152;7431;;168;;;188;7197;;203;;;223;6984;;238;;;259;7218;;274;;;294;6881;;309;;;329;7111;;344;;;365;7500;;380;;;400;7462;;415;;;435;7094;;450;;;471;6820;;486;;;506;7147;;521;;;541;9156;;558;;;578;6961;;594;;;614;;;641;;;660;7176;;676;;;696;7177;;712;;;732;7199;;747;;;767;7218;;782;;;802;7360;;817;;;838;;;865;;;884;;;904;;;924;;;943;;;962;7252;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:05;1271032;;48.723153;9.094046;496.5;189.86;3.48;1.01;7;3.74;940;;0;0;58;54;0;7295;;14;;;35;7183;;50;;;71;7283;;85;;;106;8957;;122;;;142;8178;;158;;;178;7814;;194;;;213;7495;;229;;;249;7713;;265;;;285;7305;;300;;;320;7654;;335;;;356;7687;;371;;;391;7634;;406;;;426;7167;;441;;;461;;;488;;;508;7245;;524;;;544;7283;;559;;;580;7150;;595;;;615;7194;;630;;;650;7410;;665;;;686;7670;;702;;;721;7421;;737;;;757;7588;;772;;;792;7452;;809;;;828;8162;;844;;;865;9078;;881;;;901;7563;;917;;;936;7775;;952;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:06;1272031;;48.723146;9.094036;496.5;189.86;2.44;1.01;7;3.74;1000;;0;0;58;54;0;8193;;18;;;39;7629;;54;;;74;;;102;;;121;7778;;137;;;157;7773;;172;;;193;7922;;208;;;228;7706;;243;;;263;8881;;280;;;299;7776;;315;;;334;7797;;350;;;370;8683;;386;;;406;7863;;422;;;441;7901;;457;;;477;7747;;492;;;513;8246;;529;;;549;7756;;564;;;585;7667;;600;;;620;7657;;635;;;655;;;682;;;702;8193;;719;;;738;7751;;754;;;774;7731;;789;;;809;8109;;825;;;845;7623;;860;;;880;7883;;895;;;916;7579;;931;;;951;7514;;966;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:07;1273044;;48.723134;9.094026;496.7;189.86;3.44;1.01;7;3.74;990;;0;0;58;53;0;7543;;15;;;37;7535;;52;;;72;9628;;90;;;109;8166;;125;;;146;7469;;161;;;181;7923;;197;;;216;7651;;232;;;252;7594;;267;;;288;7796;;303;;;323;7960;;338;;;359;7862;;373;;;394;7633;;409;;;429;7926;;444;;;465;7661;;479;;;500;7546;;515;;;535;7522;;550;;;570;8461;;587;;;606;7520;;622;;;643;;;668;;;688;7495;;704;;;723;7672;;739;;;759;7964;;774;;;795;8725;;811;;;831;7366;;847;;;867;7586;;882;;;902;8634;;919;;;938;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:08;1274030;;48.723126;9.094013;496.7;230.11;3.54;1.01;7;3.74;850;;0;0;58;53;0;7452;;29;;;49;7446;;65;;;91;1147;;102;;;127;7517;;143;;;163;7411;;178;;;204;1148;;214;;;240;7282;;256;;;281;1201;;291;;;316;1144;;326;;;351;1173;;362;;;386;6718;;401;;;422;7303;;437;;;461;7621;;476;;;497;7557;;511;;;532;7451;;547;;;567;7658;;583;;;603;7534;;618;;;638;7306;;653;;;673;7222;;688;;;709;7169;;724;;;744;7115;;759;;;779;7277;;794;;;815;;;841;;;861;;;881;;;900;7403;;916;;;936;7356;;951;;;972;7030;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:09;1275038;;48.723121;9.093994;496.9;237.39;3.33;1.01;7;3.74;730;;0;0;58;54;0;7327;;16;;;36;6876;;52;;;72;6953;;87;;;107;7261;;122;;;142;6702;;158;;;178;7286;;193;;;213;6605;;228;;;249;7168;;264;;;284;6641;;299;;;324;7059;;339;;;359;7568;;374;;;394;6476;;409;;;430;6589;;445;;;470;1174;;480;;;505;1173;;515;;;541;1175;;551;;;576;1149;;586;;;611;6222;;626;;;647;6722;;661;;;687;5939;;700;;;723;5989;;735;;;760;6144;;773;;;795;6019;;808;;;830;6306;;844;;;866;6169;;879;;;901;6288;;914;;;936;9882;;954;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:10;1276038;;48.723117;9.093979;497.4;247.62;2.96;1.01;7;3.74;7;69;0;;0;0;58;52;0;;;30;;;50;;;69;;;89;;;109;;6187;124;9730;;144;;6203;160;14558;;182;;6178;195;;;222;;6233;235;;;257;;6323;275;;;295;;6379;311;;;331;;6371;346;8588;;366;;6330;381;2150;;401;;6275;417;1200;;437;;6184;461;;;488;;6033;505;;;525;;5943;543;2550;;561;;5872;579;2563;;596;;5844;614;1225;;631;;5835;650;1173;;667;;5799;685;2243;;702;;5804;720;2275;;737;;5798;759;;;785;;5854;805;;;825;;5984;840;;;860;;5979;875;;;895;;6027;911;7850;;931;;6001;946;13531;;969;;5957;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:11;1277041;;48.723117;9.093965;497.5;247.62;2.74;1.01;7;3.74;143;72;0;;0;0;58;48;0;;;30;;5966;43;;;65;;5940;78;12209;;101;;5923;113;;;140;;7918;155;18175;;182;;6159;199;;;226;;6047;239;;;261;;6283;274;;;297;;;328;;;348;;6015;364;;;384;;;418;1174;;428;;6166;453;;;480;;6265;498;;;518;;6241;536;9449;;553;;6311;571;17498;;597;;6394;611;;;638;;6380;652;;;673;;6408;687;;;708;;7059;722;7897;;744;;7059;759;10810;;779;;6459;794;;;822;;6680;841;15140;;864;;;891;6403;;906;;9053;930;10084;;948;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:12;1278045;;48.723109;9.093963;498;247.62;2.17;1.79;7;3.74;143;76;0;;0;0;58;48;0;;;30;;6485;52;;;71;;6321;93;;;113;;6283;128;;;148;;6319;164;10355;;184;;6232;199;17561;;225;;6259;238;;;265;;;292;16478;;317;;;344;8916;;361;;6160;379;;;406;;6242;426;;;445;;6318;461;;;481;;6172;496;;;516;;6271;534;;;554;;6184;571;10174;;590;;6204;607;14878;;630;;6333;643;;;670;;6332;683;;;705;;6231;718;;;740;;6227;753;;;776;;;803;;;822;;6469;844;;;864;;6215;879;;;899;;;927;;;946;;6326;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:13;1279029;;48.723109;9.093963;498;247.62;0;1.79;7;3.74;116;79;0;;0;0;58;47;0;;;20;;6783;41;;;61;;6343;81;;;101;;6506;121;11871;;142;;6364;162;16368;;185;;6365;197;;;224;;;250;15312;;274;;6698;295;12786;;315;;6428;330;;;357;;6556;375;17429;;401;;6426;418;16587;;444;;6539;462;;;488;;;515;18278;;542;;6507;556;;;584;;6506;593;;;619;;6774;632;;;654;;6775;668;;;690;;;717;;;737;;;764;;;784;;6708;799;;;819;;6908;840;8503;;856;;;882;1202;;893;;6729;917;13601;;939;;;974;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:14;1280049;;48.723109;9.093963;498;247.62;0;1.1;7;3.74;121;92;0;;0;0;58;45;0;;;33;;7087;56;;;75;;7170;94;;;113;;7158;129;;;149;;7575;164;;;184;;7233;204;13424;;226;;7616;248;18289;;282;;;315;;;342;;7353;357;;;377;;7521;400;;;419;;7510;435;;;455;;;481;;;500;;7484;516;12940;;537;;;571;8777;;588;;;615;11659;;634;;;660;1174;;670;;;696;;;723;;;742;;;762;;7640;778;;;797;;7980;819;;;839;;8759;855;;;875;;7752;890;11740;;910;;7612;929;12291;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:15;1281028;;48.723109;9.093963;498;247.62;0;1.01;7;3.74;133;95;0;;0;0;58;48;0;;;30;;7564;49;12590;;70;;7576;84;;;111;;7584;120;;;146;;7794;161;;;182;;7826;196;;;217;;;244;;;264;;8057;286;;;305;;7525;321;8033;;340;;7657;360;11295;;380;;;407;9480;;423;;;451;12842;;472;;7540;487;16790;;512;;;538;1175;;549;;;574;1175;;584;;7788;609;1175;;619;;;647;;;673;;7354;697;;;716;;7287;738;;;758;;7454;773;;;793;;7286;812;9244;;829;;7388;848;17471;;874;;7246;893;;;919;;;952;8724;;968;;7324;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
18.11.2020;16:06:16;1282037;;48.723109;9.093963;498;247.62;0;1.01;7;3.74;5;89;20;;0;0;58;49;0;;;31;;7290;44;;;66;;7277;80;;;101;;;128;;;148;;7213;164;;;183;;6901;202;10902;;223;;7060;242;2257;;258;;7057;277;2124;;293;;7045;313;1201;;328;;;361;2137;;371;;6931;396;2055;;407;;6910;432;1201;;442;;;468;2042;;478;;6961;503;1201;;513;;;548;12669;;568;;6909;590;;;617;;7063;636;;;656;;7148;672;;;691;;6777;707;;;727;;6903;747;11631;;767;;;793;1174;;803;;7283;828;;;856;;;889;9154;;908;;7489;929;9129;;943;;7430;965;14679;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
`;
const test3 = `Date;Time;Millis;Comment;Latitude;Longitude;Altitude;Course;Speed;HDOP;Satellites;BatteryLevel;Left;Right;Confirmed;Marked;Invalid;InsidePrivacyArea;Factor;Measurements;Tms1;Lus1;Rus1;Tms2;Lus2;Rus2;Tms3;Lus3;Rus3;Tms4;Lus4;Rus4;Tms5;Lus5;Rus5;Tms6;Lus6;Rus6;Tms7;Lus7;Rus7;Tms8;Lus8;Rus8;Tms9;Lus9;Rus9;Tms10;Lus10;Rus10;Tms11;Lus11;Rus11;Tms12;Lus12;Rus12;Tms13;Lus13;Rus13;Tms14;Lus14;Rus14;Tms15;Lus15;Rus15;Tms16;Lus16;Rus16;Tms17;Lus17;Rus17;Tms18;Lus18;Rus18;Tms19;Lus19;Rus19;Tms20;Lus20;Rus20;Tms21;Lus21;Rus21;Tms22;Lus22;Rus22;Tms23;Lus23;Rus23;Tms24;Lus24;Rus24;Tms25;Lus25;Rus25;Tms26;Lus26;Rus26;Tms27;Lus27;Rus27;Tms28;Lus28;Rus28;Tms29;Lus29;Rus29;Tms30;Lus30;Rus30;Tms31;Lus31;Rus31;Tms32;Lus32;Rus32;Tms33;Lus33;Rus33;Tms34;Lus34;Rus34;Tms35;Lus35;Rus35;Tms36;Lus36;Rus36;Tms37;Lus37;Rus37;Tms38;Lus38;Rus38;Tms39;Lus39;Rus39;Tms40;Lus40;Rus40;Tms41;Lus41;Rus41;Tms42;Lus42;Rus42;Tms43;Lus43;Rus43;Tms44;Lus44;Rus44;Tms45;Lus45;Rus45;Tms46;Lus46;Rus46;Tms47;Lus47;Rus47;Tms48;Lus48;Rus48;Tms49;Lus49;Rus49;Tms50;Lus50;Rus50;Tms51;Lus51;Rus51;Tms52;Lus52;Rus52;Tms53;Lus53;Rus53;Tms54;Lus54;Rus54;Tms55;Lus55;Rus55;Tms56;Lus56;Rus56;Tms57;Lus57;Rus57;Tms58;Lus58;Rus58;Tms59;Lus59;Rus59;Tms60;Lus60;Rus60;
21.11.2020;14:27:00;66890;;;;;;;3.83;4;3.99;;286;0;;0;0;58;5;0;;;41;;18355;67;;;87;;18374;113;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
`;
module.exports = { test1, test2, test3 };

View file

@ -1,312 +0,0 @@
const csvParse = require('csv-parse/lib/sync');
const csvStringify = require('csv-stringify/lib/sync');
function _parseFloat(token) {
if (typeof token !== 'string') {
return null;
}
token = token.trim();
if (token === '') {
return null;
}
if (/^nan$/i.test(token)) {
return null;
}
let f = parseFloat(token);
if (isNaN(f)) {
f = parseFloat(token.substring(0, 10));
}
if (isNaN(f)) {
f = 0.0;
}
return f;
}
function _parseInt(token) {
const asFloat = _parseFloat(token);
if (asFloat !== null) {
return Math.floor(asFloat);
} else {
return asFloat;
}
}
function _parseString(token) {
if (typeof token !== 'string') {
return null;
}
// This time we do not trim -- because we assume that the quoting mechanism
// from CSV might have kicked in and we actually want the spacing around the
// token.
if (token === '') {
return null;
}
return token;
}
function replaceDollarNewlinesHack(body) {
// see if we are using the hack with $ as newlines, replace them for the csv parser
if (body.endsWith('$') || /insidePrivacyArea;\$/.test(body)) {
return body.replace(/\$/g, '\n');
}
return body;
}
function* parseTrackPoints(body, format = null) {
if (body instanceof Buffer) {
body = body.toString('utf-8')
}
body = replaceDollarNewlinesHack(body);
const detectedFormat = format != null ? format : detectFormat(body);
let parser;
switch (detectedFormat) {
case 'invalid':
throw new Error('track format cannot be detected');
case 1:
parser = parseObsver1;
break;
case 2:
parser = parseObsver2;
break;
}
yield* parser(body);
}
function detectFormat(body) {
body = replaceDollarNewlinesHack(body);
if (!body.length) {
return 'invalid';
}
const firstLinebreakIndex = body.indexOf('\n');
if (firstLinebreakIndex === -1) {
// We need at least one linebreak in the whole file, to separate header and
// data. If the file contains no header, it is in valid.
return 'invalid';
}
const firstLine = body.substring(0, firstLinebreakIndex);
const match = firstLine.match(/(^|&)OBSDataFormat=([\d]+)($|&)/);
if (match) {
return Number(match[2]);
}
// If we have no metadata line, but start immediately with a header, AND it contains
// `;Rus`, it is a version 2
if (/^Date;Time.*;Rus/.test(firstLine)) {
return 2;
}
// If we have no metadata line, but start immediately with a header, it is
// format version 1.
if (/^Date;Time/.test(firstLine)) {
return 1;
}
// If we immediately start with data (a date, formatted as DD.MM.YYYY), then
// we have an old OBS not sending the header. It must therefore be old
// format, too.
if (/^[0-9]{2}\.[0-9]{2}\.[0-9]{4};/.test(firstLine)) {
return 1;
}
return 'invalid';
}
function* parseObsver1(body) {
for (const record of csvParse(body, {
delimiter: ';',
encoding: 'utf8',
// We specify different column names here, as the order of columns was
// always the same, but their naming was different. By enforicing these
// column names we don't have to translate between them. Then we just
// ignore the first line (or any line that starts with "Date;").
// Original header usually is:
// Date;Time;Latitude;Longitude;Course;Speed;Right;Left;Confirmed;insidePrivacyArea
columns: ['date', 'time', 'latitude', 'longitude', 'course', 'speed', 'd1', 'd2', 'flag', 'private'],
relax_column_count: true,
cast(value, { column }) {
if (['latitude', 'longitude', 'course', 'speed'].includes(column)) {
return _parseFloat(value);
} else if (['d1', 'd2', 'flag'].includes(column)) {
return _parseInt(value);
} else if (column === 'private') {
return Boolean(_parseInt(value));
} else {
return _parseString(value);
}
},
})) {
if (record.date === 'Date') {
// ignore header line
continue;
}
if (!record.latitude && !record.longitude) {
// invalid record, make sure lat/lng say `null` instead of `0`
record.latitude = null;
record.longitude = null;
}
// in old format, 255 or 999 means "no measurement"
if (record.d1 === 255 || record.d1 === 999) {
record.d1 = null;
}
if (record.d2 === 255 || record.d2 === 999) {
record.d2 = null;
}
yield record;
}
}
function* parseObsver2(body) {
for (const record of csvParse(body, {
from_line: 2,
trim: true,
columns: true,
skip_empty_lines: true,
delimiter: ';',
encoding: 'utf8',
relax_column_count: true,
cast(value, context) {
if (value === '') {
return null;
}
let type;
switch (context.column) {
case 'Millis':
case 'Left':
case 'Right':
case 'Confirmed':
case 'Invalid':
case 'InsidePrivacyArea':
case 'Measurements':
case 'Satellites':
type = 'int';
break;
case 'Date':
case 'Time':
case 'Comment':
case 'Marked':
type = 'string';
break;
case 'Latitude':
case 'Longitude':
case 'Altitude':
case 'Course':
case 'Speed':
case 'HDOP':
case 'BatteryLevel':
case 'Factor':
type = 'float';
break;
default:
type = /^(Tms|Lus|Rus)/.test(context.column) ? 'int' : 'string';
}
switch (type) {
case 'int':
return _parseInt(value);
case 'float':
return _parseFloat(value);
case 'string':
return _parseString(value);
}
},
})) {
// We convert the new format back to the old format for storage here, until
// we upgrade the storage format as well to include all data. But we'll
// have to upgrade the obsApp first.
yield {
date: record.Date,
time: record.Time,
latitude: record.Latitude,
longitude: record.Longitude,
course: record.Course,
speed: record.Speed,
d1: record.Left,
d2: record.Right,
flag: Boolean(record.Confirmed),
private: Boolean(record.InsidePrivacyArea),
};
}
}
/**
* This function normalizes a User-Agent header for storage in the database. It
* make sure that we only store the user-agent if it matches the pattern
* `OBS/*`, and extracts that part of the user agent, if it contains more
* information. This is the only part we are interested in, the
* remainder is too privacy sensitive to keep.
*/
function normalizeUserAgent(userAgent) {
if (!userAgent) {
return null;
}
const match = userAgent.match(/\bOBS\/[^\s]+/);
if (match) {
return match[0];
}
return null;
}
function buildObsver1(points) {
return csvStringify(points, {
columns: [
{ key: 'date', header: 'Date' },
{ key: 'time', header: 'Time' },
{ key: 'latitude', header: 'Latitude' },
{ key: 'longitude', header: 'Longitude' },
{ key: 'course', header: 'Course' },
{ key: 'speed', header: 'Speed' },
{ key: 'd1', header: 'Right' },
{ key: 'd2', header: 'Left' },
{ key: 'flag', header: 'Confirmed' },
{ key: 'private', header: 'insidePrivacyArea' },
],
cast: {
boolean: (v) => (v ? '1' : '0'),
},
delimiter: ';',
header: true,
});
}
module.exports = {
detectFormat,
normalizeUserAgent,
parseObsver1,
parseObsver2,
parseTrackPoints,
replaceDollarNewlinesHack,
buildObsver1,
};

View file

@ -1,171 +0,0 @@
const {
buildObsver1,
detectFormat,
normalizeUserAgent,
parseObsver1,
parseObsver2,
parseTrackPoints,
replaceDollarNewlinesHack,
} = require('./tracks');
const { test1, test2, test3 } = require('./_tracks_testdata');
describe('parseTrackPoints', () => {
it('is a function', () => {
expect(typeof parseTrackPoints).toBe('function');
});
it('works on the sample data with an empty track', () => {
const points = Array.from(parseTrackPoints(test1));
expect(points).toHaveLength(324);
expect(points[0]).toEqual({
date: '12.07.2020',
time: '09:02:59',
latitude: null,
longitude: null,
course: 0,
speed: 0,
d1: null,
d2: null,
flag: 0,
private: false,
});
});
});
describe('parseObsver1', () => {
it('can parse sample data', () => {
const points = Array.from(parseObsver1(replaceDollarNewlinesHack(test1)));
expect(points).toHaveLength(324);
expect(points[0]).toEqual({
date: '12.07.2020',
time: '09:02:59',
latitude: null,
longitude: null,
course: 0,
speed: 0,
d1: null,
d2: null,
flag: 0,
private: false,
});
});
});
describe('parseObsver2', () => {
it('can parse sample data', () => {
const points = Array.from(parseObsver2(test2));
expect(points).toHaveLength(18);
expect(points[0]).toEqual({
date: '18.11.2020',
time: '16:05:59',
latitude: 48.723224,
longitude: 9.094103,
course: 189.86,
speed: 3.2,
d1: 770,
d2: null,
flag: false,
private: true,
});
// this is a non-private, flagged point (i.e. "Confirmed" overtaking)
expect(points[17]).toEqual({
date: '18.11.2020',
time: '16:06:16',
latitude: 48.723109,
longitude: 9.093963,
course: 247.62,
speed: 0,
d1: 5,
d2: 89,
flag: true,
private: false,
});
});
});
describe('detectFormat', () => {
it('detects format 1', () => {
expect(detectFormat(test1)).toBe(1);
});
it('detects format 2', () => {
expect(detectFormat(test2)).toBe(2);
expect(detectFormat(test3)).toBe(2);
});
it('detects invalid format', () => {
expect(detectFormat('foobar\nbaz')).toBe('invalid');
expect(detectFormat('')).toBe('invalid');
});
});
describe('normalizeUserAgent', () => {
it('is a function', () => {
expect(typeof normalizeUserAgent).toBe('function');
});
it('ignores falsy values', () => {
expect(normalizeUserAgent(null)).toBe(null);
expect(normalizeUserAgent('')).toBe(null);
});
it('ignores normal browser agents', () => {
const browserAgents = [
'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 6P Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.83 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 6.0; HTC One M9 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.98 Mobile Safari/537.3',
'Mozilla/5.0 (Linux; Android 8.0.0; SM-G960F Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.84 Mobile Safari/537.36',
'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A5370a Safari/604.1',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1',
];
for (const browserAgent of browserAgents) {
expect(normalizeUserAgent(browserAgent)).toBe(null);
}
});
it('detects OBS versions', () => {
const agents = ['OBS/123', 'OBS/2', 'OBS/1.2.3.4.5-rc123'];
for (const agent of agents) {
expect(normalizeUserAgent(agent)).toBe(agent);
}
});
it('extracts OBS versions from extended formats', () => {
const agents = ['foo OBS/123', 'OBS/123 bar', 'foo OBS/123 bar'];
for (const agent of agents) {
expect(normalizeUserAgent(agent)).toBe('OBS/123');
}
});
});
describe('buildObsver1', () => {
it('is a function', () => {
expect(typeof normalizeUserAgent).toBe('function');
});
it('transforms properly back and forth', () => {
const inputString = replaceDollarNewlinesHack(test1);
const points1 = Array.from(parseObsver1(inputString));
const builtString = buildObsver1(points1);
const points2 = Array.from(parseObsver1(builtString));
expect(points2).toEqual(points1);
});
it('produces a header', () => {
const builtString = buildObsver1([]);
expect(builtString).toBe('Date;Time;Latitude;Longitude;Course;Speed;Right;Left;Confirmed;insidePrivacyArea\n');
});
it('produces empty rows', () => {
const builtString = buildObsver1([{}]);
expect(builtString).toBe(
'Date;Time;Latitude;Longitude;Course;Speed;Right;Left;Confirmed;insidePrivacyArea\n;;;;;;;;;\n',
);
});
});

View file

@ -1,49 +0,0 @@
const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto');
const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
clientId: { type: String, required: true },
expiresAt: { type: Date, required: true },
scope: { type: String, required: true, defaultValue: '*' },
},
{ timestamps: true },
);
schema.plugin(uniqueValidator, { message: 'reused token' });
class AccessTokenClass extends mongoose.Model {
toJSON() {
return {
token: this.token,
expires: this.expires,
};
}
isValid() {
return this.expiresAt < new Date();
}
toHeaderString() {
return 'Bearer ' + this.token;
}
static generate(options, expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new AccessToken({
...options,
token,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
});
}
}
schema.loadClass(AccessTokenClass);
const AccessToken = mongoose.model('AccessToken', schema);
module.exports = AccessToken;

View file

@ -1,32 +0,0 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const schema = new mongoose.Schema(
{
code: { type: String, unique: true, required: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
clientId: { type: String, required: true },
scope: { type: String, required: true, defaultValue: '*' },
redirectUri: { type: String, required: true },
expiresAt: { type: Date, required: true },
codeChallenge: { type: String, required: true }, // no need to store the method, it is always "S256"
},
{ timestamps: true },
);
class AuthorizationCodeClass extends mongoose.Model {
static generate(options, expiresInSeconds = 60) {
const code = crypto.randomBytes(8).toString('hex');
return new AuthorizationCode({
...options,
code,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
});
}
}
schema.loadClass(AuthorizationCodeClass)
const AuthorizationCode = mongoose.model('AuthorizationCode', schema);
module.exports = AuthorizationCode

View file

@ -1,24 +0,0 @@
const mongoose = require('mongoose');
const schema = new mongoose.Schema(
{
body: String,
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
track: { type: mongoose.Schema.Types.ObjectId, ref: 'Track' },
},
{ timestamps: true },
);
class CommentClass extends mongoose.Model {
toJSONFor(user) {
return {
id: this._id,
body: this.body,
createdAt: this.createdAt,
author: this.author.toProfileJSONFor(user),
};
}
}
schema.loadClass(CommentClass)
module.exports = mongoose.model('Comment', schema);

View file

@ -1,46 +0,0 @@
const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto');
const AccessToken = require('./AccessToken')
const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
clientId: { type: String, required: true },
expiresAt: { type: Date, required: false },
scope: { type: String, required: true, defaultValue: '*' },
},
{ timestamps: true },
);
schema.plugin(uniqueValidator, { message: 'reused token' });
class RefreshTokenClass extends mongoose.Model {
toJSON() {
return {
token: this.token,
expires: this.expires,
};
}
isValid() {
return this.expiresAt == null || this.expiresAt < new Date()
}
static generate(options, expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new RefreshToken({
...options,
token,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
});
}
}
schema.loadClass(RefreshTokenClass);
const RefreshToken = mongoose.model('RefreshToken', schema);
module.exports = RefreshToken

View file

@ -1,306 +0,0 @@
const crypto = require('crypto');
const mongoose = require('mongoose');
const _ = require('lodash');
const uniqueValidator = require('mongoose-unique-validator');
const { DateTime } = require('luxon');
const slug = require('slug');
const path = require('path');
const sanitize = require('sanitize-filename');
const fs = require('fs');
const uuid = require('uuid/v4');
const { TRACKS_DIR } = require('../paths');
const queue = require('../queue');
const statisticsSchema = new mongoose.Schema(
{
recordedAt: Date,
recordedUntil: Date,
duration: Number,
length: Number,
segments: Number,
numEvents: Number,
numMeasurements: Number,
numValid: Number,
},
{ timestamps: false },
);
const schema = new mongoose.Schema(
{
// A (partially or entirely random generated) string that can be used as a
// public identifier
slug: { type: String, lowercase: true, unique: true },
// The title for this track.
title: String,
// The status of this track, whether it is to be processed, is currently
// being processed, or has completed or errored.
processingStatus: {
type: String,
enum: ['pending', 'processing', 'complete', 'error'],
default: 'pending',
},
processingJobId: String,
// Output from the proccessing routines regarding this track. Might be
// displayed to the owner or administrators to help in debugging. Should be
// set to `null` if no processing has not been finished.
processingLog: String,
// Set to true if the user customized the title. Disables auto-generating
// an updated title when the track is (re-)processed.
customizedTitle: { type: Boolean, default: false },
// A user-provided description of the track. May contain markdown.
description: String,
// Whether this track is visible (anonymized) in the public track list or not.
public: { type: Boolean, default: false },
// Whether this track should be exported to the public track database
// (after anonymization).
includeInPublicDatabase: { type: Boolean, default: false },
// The user agent string, or a part thereof, that was used to upload this
// track. Usually contains only the OBS version, other user agents are
// discarded due to being irrelevant.
uploadedByUserAgent: String,
// The name of the original file, as provided during upload. Used for
// providing a download with the same name, and for display in the
// frontend.
originalFileName: {
type: String,
required: true,
validate: {
validator: function (v) {
// Must be a sane filename, i.e. not change when being sanitized
return sanitize(v) === v && v.length > 0 && /.+\.csv$/i.test(v);
},
message: (props) => `${props.value} is not a valid filename`,
},
},
// A hash of the original file's contents. Nobody can upload the same track twice.
originalFileHash: {
type: String,
required: true,
},
// Where the files are stored, relative to a group directory like
// TRACKS_DIR or PROCESSING_DIR.
filePath: String,
comments: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }],
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
statistics: statisticsSchema,
},
{ timestamps: true },
);
schema.index({ author: 1, originalFileHash: 1 }, { unique: true });
schema.plugin(uniqueValidator, { message: 'is already taken' });
schema.pre('validate', async function (next) {
try {
if (!this.slug) {
this.slugify();
}
if (!this.filePath) {
await this.generateFilePath();
}
next();
} catch (err) {
next(err);
}
});
// 0..4 Night, 4..10 Morning, 10..14 Noon, 14..18 Afternoon, 18..22 Evening, 22..00 Night
// Two hour intervals
const DAYTIMES = [
'Night', // 0h - 2h
'Night', // 2h - 4h
'Morning', // 4h - 6h
'Morning', // 6h - 8h
'Morning', // 8h - 10h
'Noon', // 10h - 12h
'Noon', // 12h - 14h
'Afternoon', // 14h - 16h
'Afternoon', // 16h - 18h
'Evening', // 18h - 20h
'Evening', // 20h - 22h
'Night', // 22h - 24h
];
function getDaytime(dateTime) {
return DAYTIMES[Math.floor((dateTime.hour % 24) / 2)];
}
class TrackClass extends mongoose.Model {
slugify() {
this.slug = slug(this.title || 'track') + '-' + ((Math.random() * Math.pow(36, 6)) | 0).toString(36);
}
async generateFilePath() {
await this.populate('author');
this.filePath = path.join(this.author.username, this.slug);
}
isVisibleTo(user) {
if (this.public) {
return true;
}
if (!user) {
return false;
}
if (user._id.equals(this.author._id)) {
return true;
}
return false;
}
isVisibleToPrivate(user) {
return user && user._id.equals(this.author._id);
}
async _ensureDirectoryExists() {
if (!this.filePath) {
await this.generateFilePath();
}
const dir = path.dirname(this.getOriginalFilePath());
await fs.promises.mkdir(dir, { recursive: true });
}
getOriginalFilePath() {
if (!this.filePath) {
throw new Error('Cannot get original file path, `filePath` is not yet set. Call `generateFilePath()` first.');
}
return path.join(TRACKS_DIR, this.filePath, 'original.csv');
}
async writeToOriginalFile(fileBody) {
await this._ensureDirectoryExists();
await fs.promises.writeFile(this.getOriginalFilePath(), fileBody);
}
async validateFileBodyUniqueness(fileBody) {
// Generate hash
const hash = crypto.createHash('sha512').update(fileBody).digest('hex');
const existingTracks = await Track.find({ originalFileHash: hash, author: this.author });
if (existingTracks.length === 0 || (existingTracks.length === 1 && existingTracks[0]._id.equals(this._id))) {
this.originalFileHash = hash;
return;
}
throw new Error('Track file already uploaded.');
}
/**
* Marks this track as needing processing.
*
* Also deletes all stored information that is derived during processing from
* the database, such that it may be filled again with correct information
* during the processing operation.
*
* Saves the track as well, so it is up to date when the worker receives it.
*/
async queueProcessing() {
this.processingStatus = 'pending';
this.processingLog = null;
this.processingJobId = uuid();
this.statistics = null;
await this.save();
await queue.add(
'processTrack',
{
trackId: this._id.toString(),
},
{
jobId: this.processingJobId,
},
);
}
async readProcessingResults(success = true) {
// Copies some information into this object from the outputs of the
// processing step. This allows general statistics to be formed, and other
// information to be displayed, without having to read individual files
// from disk. Each field set here should be unsed in `queueProcessing`.
// This routine also moves the `processingStatus` along.
}
async autoGenerateTitle() {
if (this.customizedTitle) {
return;
}
// Try to figure out when this file was recorded. Either we have it in then
// statistics, e.g. after parsing and processing the track, or we can maybe
// derive it from the filename.
let recordedAt = null;
if (this.statistics && this.statistics.recordedAt != null) {
recordedAt = DateTime.fromJSDate(this.statistics.recordedAt);
} else if (this.originalFileName) {
const match = this.originalFileName.match(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}\.[0-9]{2}\.[0-9]{2}/);
if (match) {
recordedAt = DateTime.fromFormat(match[0], "yyyy-MM-dd'T'HH.mm.ss");
if (!recordedAt.isValid) {
recordedAt = null;
}
}
}
if (recordedAt) {
const daytime = getDaytime(recordedAt);
this.title = `${daytime} ride on ${recordedAt.toLocaleString(recordedAt.DATE_MED)}`;
await this.save();
return;
}
// Detecting recording date failed, use filename
if (this.originalFileName) {
this.title = _.upperFirst(_.words(this.originalFileName.replace(/(\.obsdata)?\.csv$/, '')).join(' '));
await this.save();
}
}
toJSONFor(user) {
const includePrivateFields = user && user._id.equals(this.author._id);
return {
slug: this.slug,
title: this.title,
description: this.description,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
public: this.public,
author: this.author.toProfileJSONFor(user),
statistics: this.statistics,
processingStatus: this.processingStatus,
...(includePrivateFields
? {
uploadedByUserAgent: this.uploadedByUserAgent,
originalFileName: this.originalFileName,
}
: {}),
};
}
}
schema.loadClass(TrackClass);
const Track = mongoose.model('Track', schema);
module.exports = Track;

View file

@ -1,91 +0,0 @@
const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const config = require('../config')
const schema = new mongoose.Schema(
{
username: {
type: String,
lowercase: true,
unique: true,
required: [true, "can't be blank"],
match: [/^[a-zA-Z0-9]+$/, 'is invalid'],
index: true,
},
email: {
type: String,
lowercase: true,
unique: true,
required: [true, "can't be blank"],
match: [/\S+@\S+\.\S+/, 'is invalid'],
index: true,
},
bio: String,
image: String,
areTracksVisibleForAll: Boolean,
hash: String,
salt: String,
needsEmailValidation: Boolean,
verificationToken: String,
resetToken: {
token: String,
expires: Date,
},
},
{ timestamps: true },
);
schema.plugin(uniqueValidator, { message: 'ist bereits vergeben. Sorry!' });
class UserClass extends mongoose.Model {
validPassword(password) {
const hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
return this.hash === hash;
}
setPassword(password) {
this.salt = crypto.randomBytes(16).toString('hex');
this.hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
}
generateJWT() {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
return jwt.sign(
{
id: this._id,
username: this.username,
exp: parseInt(exp.getTime() / 1000),
},
config.jwtSecret,
);
}
toAuthJSON() {
return {
username: this.username,
email: this.email,
token: this.generateJWT(),
bio: this.bio,
image: this.image,
areTracksVisibleForAll: this.areTracksVisibleForAll,
apiKey: this._id,
};
}
toProfileJSONFor(user) {
return {
username: this.username,
bio: this.bio,
image: this.image,
};
}
}
schema.loadClass(UserClass);
const User = mongoose.model('User', schema);
module.exports = User

View file

@ -1,6 +0,0 @@
module.exports.AccessToken = require('./AccessToken')
module.exports.AuthorizationCode = require('./AuthorizationCode')
module.exports.Comment = require('./Comment')
module.exports.RefreshToken = require('./RefreshToken')
module.exports.Track = require('./Track')
module.exports.User = require('./User')

View file

@ -1,215 +0,0 @@
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const { Strategy: BearerStrategy } = require('passport-http-bearer');
const { Strategy: JwtStrategy } = require('passport-jwt');
const { Strategy: CustomStrategy } = require('passport-custom');
const { User, AccessToken, RefreshToken } = require('./models');
const config = require('./config');
// used to serialize the user for the session
passport.serializeUser(function (user, done) {
done(null, user._id);
});
// used to deserialize the user
passport.deserializeUser(function (id, done) {
User.findById(id, function (err, user) {
done(err, user);
});
});
async function loginWithPassword(email, password, done) {
try {
const user = await User.findOne({ email: email });
if (!user || !user.validPassword(password)) {
return done(new Error('invalid credentials'), false);
}
// Regardless of whether login is required, if you're logged in as an
// unverified user, produce an error.
if (user.needsEmailValidation) {
return done(new Error('email not verified'), false);
}
return done(null, user);
} catch (err) {
done(err);
}
}
passport.use(
'usernameAndPassword',
new LocalStrategy(
{
usernameField: 'user[email]',
passwordField: 'user[password]',
session: false,
},
loginWithPassword,
),
);
passport.use(
'usernameAndPasswordSession',
new LocalStrategy(
{
usernameField: 'email',
passwordField: 'password',
session: true,
},
loginWithPassword,
),
);
function getRequestToken(req, tokenTypes = ['Token', 'Bearer']) {
const authorization = req.headers.authorization;
if (typeof authorization !== 'string') {
return null;
}
const [tokenType, token] = authorization.split(' ');
if (tokenTypes.includes(tokenType)) {
return token;
}
return null;
}
passport.use(
'jwt',
new JwtStrategy(
{
secretOrKey: config.jwtSecret,
jwtFromRequest: getRequestToken,
algorithms: ['HS256'],
},
async function (token, done) {
try {
// we used to put the user ID into the token directly :(
const { id } = token;
const user = await User.findById(id);
return done(null, user || false);
} catch (err) {
return done(err);
}
},
),
);
passport.use(
'accessToken',
new BearerStrategy(async function (token, done) {
try {
const accessToken = await AccessToken.findOne({ token }).populate('user');
if (accessToken && accessToken.user) {
// TODO: scope
return done(null, accessToken.user, { scope: accessToken.scope });
} else {
return done(null, false);
}
} catch (err) {
return done(err);
}
}),
);
passport.use(
'refreshToken',
new BearerStrategy(async function (token, done) {
try {
const refreshToken = await RefreshToken.findOne({ token }).populate('user');
if (refreshToken && refreshToken.user) {
// TODO: scope
return done(null, refreshToken.user, { scope: 'auth.refresh' });
} else {
return done(null, false);
}
} catch (err) {
return done(err);
}
}),
);
passport.use(
'userId',
new CustomStrategy(async (req, callback) => {
try {
let userId;
const headerToken = getRequestToken(req, ['OBSUserId']);
if (headerToken && headerToken.length === 24) {
userId = headerToken;
}
if (!userId) {
const bodyId = req.body && req.body.id;
if (bodyId && bodyId.length === 24) {
userId = bodyId;
}
}
let user;
if (userId) {
user = await User.findById(userId);
}
callback(null, user || false);
} catch (err) {
callback(err);
}
}),
);
/**
* This function creates a middleware that does a passport authentication.
*/
function createMiddleware(strategies, required = true, session = false) {
return (req, res, next) => {
passport.authenticate(strategies, { session }, (err, user, info) => {
// If this authentication produced an error, throw it. In a chain of
// multiple strategies, errors are ignored, unless every strategy errors.
if (err) {
return next(err);
}
// If you *must* be logged in for this action, require a user.
if (required && !user) {
return res.sendStatus(403);
}
// Regardless of whether login is required, if you're logged in as an
// unverified user, produce an error.
if (user && user.needsEmailValidation) {
return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
}
req.user = user;
req.scope = (info && info.scope) || '*';
return next();
})(req, res, next);
};
}
module.exports = {
// these are the standard authentication mechanisms, for when you want user
// information in the route, and either require a login, or don't care
optional: createMiddleware(['jwt', 'accessToken'], false),
required: createMiddleware(['jwt', 'accessToken'], true),
// required to check username and passwort for generating a new token, e.g.
// on the /users/login route, and later on oauth routes
usernameAndPassword: createMiddleware('usernameAndPassword', true),
usernameAndPasswordSession: createMiddleware('usernameAndPasswordSession', false, true),
// will be used to verify a refresh token on the route that will exchange the
// refresh token for a new access token (not in use yet)
refreshToken: createMiddleware('refreshToken', true),
// for track upload, we still allow "userId" for a while
requiredWithUserId: createMiddleware(['jwt', 'accessToken', 'userId'], true),
};

View file

@ -1,29 +0,0 @@
const path = require('path');
const API_ROOT_DIR = path.resolve(__dirname, '../');
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../../data/');
// Contains the subtree for processing files
const PROCESSING_DIR = path.join(DATA_DIR, 'processing');
const PROCESSING_OUTPUT_DIR = path.join(DATA_DIR, 'processing-output');
// Contains the subtree for processing files, without privatization techniques,
// used only for display of tracks to authors
const PROCESSING_DIR_PRIVATE = path.join(DATA_DIR, 'private');
// Contains original track files
const TRACKS_DIR = path.join(DATA_DIR, 'tracks');
// Cache directory for all obs-face calls
const OBS_FACE_CACHE_DIR = path.join(DATA_DIR, 'obs-face-cache');
module.exports = {
API_ROOT_DIR,
DATA_DIR,
PROCESSING_DIR,
PROCESSING_OUTPUT_DIR,
PROCESSING_DIR_PRIVATE,
TRACKS_DIR,
OBS_FACE_CACHE_DIR,
};

View file

@ -1,12 +0,0 @@
const Bull = require('bull');
const config = require('./config');
module.exports = new Bull('processQueue', config.redisUrl, {
settings: {
// if the worker process is killed and restarted, e.g. due to reboot or
// upgrade, it is okay to wait for a timeout on the job and restart it
maxStalledCount: 3,
lockDuration: 120 * 1000,
},
});

View file

@ -1,25 +0,0 @@
const router = require('express').Router();
router.use('/', require('./users'));
router.use('/profiles', require('./profiles'));
router.use('/tracks', require('./tracks'));
router.use('/tags', require('./tags'));
router.use('/accounts', require('../../accounts/accounts.controller'));
router.use('/stats', require('./stats'));
router.use('/info', require('./info'));
router.use(function (err, req, res, next) {
if (err.name === 'ValidationError') {
return res.status(422).json({
errors: Object.keys(err.errors).reduce(function (errors, key) {
errors[key] = err.errors[key].message;
return errors;
}, {}),
});
}
return next(err);
});
module.exports = router;

View file

@ -1,11 +0,0 @@
const router = require('express').Router();
const { version } = require('../../../package.json');
router.route('/').get((req, res) => {
res.json({
version,
});
});
module.exports = router;

View file

@ -1,30 +0,0 @@
const router = require('express').Router();
const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../../passport');
const { User } = require('../../models');
// Preload user profile on routes with ':username'
router.param('username', async function (req, res, next, username) {
try {
const user = await User.findOne({ username: username });
if (!user) {
return res.sendStatus(404);
}
req.profile = user;
return next();
} catch (err) {
next(err);
}
});
router.get(
'/:username',
auth.optional,
wrapRoute(async (req, res) => {
return res.json({ profile: req.profile.toProfileJSONFor(req.user) });
}),
);
module.exports = router;

View file

@ -1,107 +0,0 @@
const router = require('express').Router();
const { DateTime } = require('luxon');
const { Track, User } = require('../../models');
const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../../passport');
// round to this number of meters for privacy reasons
const TRACK_LENGTH_ROUNDING = 1000;
// round to this number of seconds for privacy reasons
const TRACK_DURATION_ROUNDING = 120;
router.get(
'/',
auth.optional,
wrapRoute(async (req, res) => {
const start = DateTime.fromISO(req.query.start);
const end = DateTime.fromISO(req.query.end);
const dateFilter = {
$ne: null,
...(start.isValid ? { $gte: start.toJSDate() } : {}),
...(end.isValid ? { $lt: end.toJSDate() } : {}),
};
let userFilter;
if (req.query.user) {
const user = await User.findOne({ username: req.query.user });
// Only the user can look for their own stats, for now
if (req.user && req.user._id.equals(user._id)) {
userFilter = user._id;
} else {
userFilter = { $ne: null };
}
}
const trackFilter = {
'statistics.recordedAt': dateFilter,
...(userFilter ? { author: userFilter } : {}),
};
const trackCount = await Track.find(trackFilter).count();
const publicTrackCount = await Track.find({
...trackFilter,
public: true,
}).count();
const userCount = await User.find({
...(userFilter
? { _id: userFilter }
: {
createdAt: dateFilter,
}),
}).count();
const trackStats = await Track.aggregate([
{ $match: trackFilter },
{
$addFields: {
trackLength: {
$cond: [{ $lt: ['$statistics.length', 500000] }, '$statistics.length', 0],
},
numEvents: '$statistics.numEvents',
trackDuration: {
$cond: [
{ $and: ['$statistics.recordedUntil', { $gt: ['$statistics.recordedAt', new Date('2010-01-01')] }] },
{ $subtract: ['$statistics.recordedUntil', '$statistics.recordedAt'] },
0,
],
},
},
},
{ $project: { trackLength: true, numEvents: true, trackDuration: true } },
{
$group: {
_id: 'sum',
trackLength: { $sum: '$trackLength' },
numEvents: { $sum: '$numEvents' },
trackDuration: { $sum: '$trackDuration' },
},
},
]);
const [trackLength, numEvents, trackDuration] =
trackStats.length > 0
? [trackStats[0].trackLength, trackStats[0].numEvents, trackStats[0].trackDuration]
: [0, 0, 0];
const trackLengthPrivatized = Math.floor(trackLength / TRACK_LENGTH_ROUNDING) * TRACK_LENGTH_ROUNDING;
const trackDurationPrivatized =
Math.round(trackDuration / 1000 / TRACK_DURATION_ROUNDING) * TRACK_DURATION_ROUNDING;
return res.json({
publicTrackCount,
trackLength: trackLengthPrivatized,
trackDuration: trackDurationPrivatized,
numEvents,
trackCount,
userCount,
});
}),
);
module.exports = router;

View file

@ -1,14 +0,0 @@
const router = require('express').Router();
const wrapRoute = require('../../_helpers/wrapRoute');
const { Track } = require('../../models');
// return a list of tags
router.get(
'/',
wrapRoute(async (req, res) => {
const tags = await Track.find().distinct('tagList');
return res.json({ tags: tags });
}),
);
module.exports = router;

View file

@ -1,416 +0,0 @@
const fs = require('fs');
const path = require('path');
const router = require('express').Router();
const { Track, User, Comment } = require('../../models');
const busboy = require('connect-busboy');
const auth = require('../../passport');
const { normalizeUserAgent, buildObsver1 } = require('../../logic/tracks');
const wrapRoute = require('../../_helpers/wrapRoute');
const { PROCESSING_OUTPUT_DIR } = require('../../paths');
function preloadByParam(target, getValueFromParam) {
return async (req, res, next, paramValue) => {
try {
const value = await getValueFromParam(paramValue);
if (!value) {
return res.sendStatus(404);
}
req[target] = value;
return next();
} catch (err) {
return next(err);
}
};
}
router.param(
'track',
preloadByParam('track', (slug) => Track.findOne({ slug }).populate('author')),
);
router.param(
'comment',
preloadByParam('comment', (id) => Comment.findById(id)),
);
router.param('comment', async (req, res, next, id) => {
try {
const comment = await Comment.findById(id);
if (!comment) {
return res.sendStatus(404);
}
req.comment = comment;
return next();
} catch (err) {
return next(err);
}
});
router.get(
'/',
auth.optional,
wrapRoute(async (req, res) => {
const query = { public: true };
let limit = 20;
let offset = 0;
if (typeof req.query.limit !== 'undefined') {
limit = req.query.limit;
}
if (typeof req.query.offset !== 'undefined') {
offset = req.query.offset;
}
if (typeof req.query.tag !== 'undefined') {
query.tagList = { $in: [req.query.tag] };
}
const author = req.query.author ? await User.findOne({ username: req.query.author }) : null;
if (author) {
query.author = author._id;
}
const [tracks, tracksCount] = await Promise.all([
Track.find(query)
.sort('-createdAt')
.limit(Number(limit))
.skip(Number(offset))
.sort({ createdAt: 'desc' })
.populate('author')
.exec(),
Track.countDocuments(query).exec(),
]);
return res.json({
tracks: tracks.map((track) => track.toJSONFor(req.user)),
tracksCount,
});
}),
);
router.get(
'/feed',
auth.required,
wrapRoute(async (req, res) => {
let limit = 20;
let offset = 0;
if (typeof req.query.limit !== 'undefined') {
limit = req.query.limit;
}
if (typeof req.query.offset !== 'undefined') {
offset = req.query.offset;
}
const query = { author: req.user.id };
const [tracks, tracksCount] = await Promise.all([
Track.find(query).sort('-createdAt').limit(Number(limit)).skip(Number(offset)).populate('author').exec(),
Track.countDocuments(query),
]);
return res.json({
tracks: tracks.map(function (track) {
return track.toJSONFor(req.user);
}),
tracksCount: tracksCount,
});
}),
);
async function readFile(file) {
let fileContent = '';
file.on('data', function (data) {
fileContent += data;
});
await new Promise((resolve, reject) => {
file.on('end', resolve);
file.on('error', reject);
});
return fileContent;
}
async function getMultipartOrJsonBody(req, mapJsonBody = (x) => x) {
const fileInfo = {};
let body;
if (req.busboy) {
body = {};
req.busboy.on('file', async function (fieldname, file, filename, encoding, mimetype) {
body[fieldname] = await readFile(file);
fileInfo[fieldname] = { filename, encoding, mimetype };
});
req.busboy.on('field', (key, value) => {
body[key] = value;
});
req.pipe(req.busboy);
await new Promise((resolve, reject) => {
req.busboy.on('finish', resolve);
req.busboy.on('error', reject);
});
} else if (req.headers['content-type'] === 'application/json') {
body = mapJsonBody(req.body);
} else {
body = { body: await readFile(req), ...req.query };
fileInfo.body = {
mimetype: req.headers['content-type'],
filename: req.headers['content-disposition'],
encoding: req.headers['content-encoding'],
};
}
return { body, fileInfo };
}
router.post(
'/',
auth.requiredWithUserId,
busboy(), // parse multipart body
wrapRoute(async (req, res) => {
// Read the whole file into memory. This is not optimal, instead, we should
// write the file data directly to the target file. However, we first have
// to parse the rest of the track data to know where to place the file.
// TODO: Stream into temporary file, then move it later.
const { body, fileInfo } = await getMultipartOrJsonBody(req, (body) => body.track);
const { body: fileBody, public, ...trackBody } = body;
const track = new Track({
...trackBody,
author: req.user,
public: public == null ? req.user.areTracksVisibleForAll : Boolean(trackBody.public),
});
track.customizedTitle = track.title != null;
track.slugify();
if (fileBody) {
await track.validateFileBodyUniqueness(fileBody);
track.uploadedByUserAgent = normalizeUserAgent(req.headers['user-agent']);
track.originalFileName = fileInfo.body ? fileInfo.body.filename : track.slug + '.csv';
await track.writeToOriginalFile(fileBody);
}
await track.save();
if (fileBody) {
await track.queueProcessing();
}
await track.autoGenerateTitle();
return res.json({ track: track.toJSONFor(req.user) });
}),
);
// return a track
router.get(
'/:track',
auth.optional,
wrapRoute(async (req, res) => {
if (!req.track.isVisibleTo(req.user)) {
return res.sendStatus(403);
}
return res.json({ track: req.track.toJSONFor(req.user) });
}),
);
// update track
router.put(
'/:track',
busboy(),
auth.required,
wrapRoute(async (req, res) => {
const track = req.track;
if (!track.author._id.equals(req.user.id)) {
return res.sendStatus(403);
}
const {
body: { body: fileBody, ...trackBody },
fileInfo,
} = await getMultipartOrJsonBody(req, (body) => body.track);
if (typeof trackBody.title !== 'undefined') {
track.title = (trackBody.title || '').trim() || null;
track.customizedTitle = track.title != null;
}
if (typeof trackBody.description !== 'undefined') {
track.description = (trackBody.description || '').trim() || null;
}
let process = false;
if (trackBody.public != null) {
const public = Boolean(trackBody.public);
process |= public !== track.public;
track.public = public;
}
if (fileBody) {
await track.validateFileBodyUniqueness(fileBody);
track.originalFileName = fileInfo.body ? fileInfo.body.filename : track.slug + '.csv';
track.uploadedByUserAgent = normalizeUserAgent(req.headers['user-agent']);
await track.writeToOriginalFile(fileBody);
process = true;
}
await track.save();
if (process) {
await track.queueProcessing();
}
await track.autoGenerateTitle();
return res.json({ track: track.toJSONFor(req.user) });
}),
);
// delete track
router.delete(
'/:track',
auth.required,
wrapRoute(async (req, res) => {
if (req.track.author._id.equals(req.user.id)) {
await req.track.remove();
return res.sendStatus(204);
} else {
return res.sendStatus(403);
}
}),
);
// return an track's comments
router.get(
'/:track/comments',
auth.optional,
wrapRoute(async (req, res) => {
if (!req.track.isVisibleTo(req.user)) {
return res.sendStatus(403);
}
await req.track.populate({
path: 'comments',
populate: {
path: 'author',
},
options: {
sort: {
createdAt: 'asc',
},
},
});
return res.json({
comments: req.track.comments.map(function (comment) {
return comment.toJSONFor(req.user);
}),
});
}),
);
// create a new comment
router.post(
'/:track/comments',
auth.required,
wrapRoute(async (req, res) => {
const comment = new Comment(req.body.comment);
comment.track = req.track;
comment.author = req.user;
await comment.save();
req.track.comments.push(comment);
await req.track.save();
return res.json({ comment: comment.toJSONFor(req.user) });
}),
);
router.delete(
'/:track/comments/:comment',
auth.required,
wrapRoute(async (req, res) => {
if (req.comment.author.equals(req.user.id)) {
req.track.comments.remove(req.comment._id);
await req.track.save();
await Comment.find({ _id: req.comment._id }).remove();
res.sendStatus(204);
} else {
res.sendStatus(403);
}
}),
);
// return an track's generated data
router.get(
'/:track/data',
auth.optional,
wrapRoute(async (req, res) => {
const FILE_BY_KEY = {
measurements: 'measurements.json',
overtakingEvents: 'overtakingEvents.json',
track: 'track.json',
};
if (!req.track.isVisibleTo(req.user)) {
return res.sendStatus(403);
}
const result = {};
for (const [key, filename] of Object.entries(FILE_BY_KEY)) {
const filePath = path.join(PROCESSING_OUTPUT_DIR, req.track.filePath, filename);
let stats;
try {
stats = await fs.promises.stat(filePath);
} catch (err) {
continue;
}
if (!stats.isFile()) {
// file does not exist (yet)
continue;
}
const content = await fs.promises.readFile(filePath);
const contentJson = JSON.parse(content);
result[key] = contentJson;
}
return res.json(result);
}),
);
// download the original file
router.get(
'/:track/download/original.csv',
auth.optional,
wrapRoute(async (req, res) => {
if (!req.track.isVisibleToPrivate(req.user)) {
return res.sendStatus(403);
}
return res.download(req.track.getOriginalFilePath(), req.track.originalFileName);
}),
);
module.exports = router;

View file

@ -1,52 +0,0 @@
const router = require('express').Router();
const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../../passport');
router.get(
'/user',
auth.required,
wrapRoute(async (req, res) => {
return res.json({ user: req.user.toAuthJSON() });
}),
);
router.put(
'/user',
auth.required,
wrapRoute(async (req, res) => {
const user = req.user;
// only update fields that were actually passed...
if (typeof req.body.user.username !== 'undefined') {
user.username = req.body.user.username;
}
if (typeof req.body.user.email !== 'undefined') {
user.email = req.body.user.email;
}
if (typeof req.body.user.bio !== 'undefined') {
user.bio = req.body.user.bio;
}
if (typeof req.body.user.image !== 'undefined') {
user.image = req.body.user.image;
}
if (typeof req.body.user.areTracksVisibleForAll !== 'undefined') {
user.areTracksVisibleForAll = req.body.user.areTracksVisibleForAll;
}
if (typeof req.body.user.password === 'string' && req.body.user.password !== '') {
user.setPassword(req.body.user.password);
}
await user.save();
return res.json({ user: user.toAuthJSON() });
}),
);
// Remove this at some point
router.post('/users/login',
auth.usernameAndPassword,
wrapRoute((req, res) => {
return res.json({ user: req.user.toAuthJSON() });
}),
);
module.exports = router;

View file

@ -1,558 +0,0 @@
const router = require('express').Router();
const passport = require('passport');
const { URL } = require('url');
const { createChallenge } = require('pkce');
const { AuthorizationCode, AccessToken, RefreshToken } = require('../models');
const auth = require('../passport');
const wrapRoute = require('../_helpers/wrapRoute');
const config = require('../config');
const baseUrl = config.baseUrl.replace(/\/+$/, '');
// Check whether the "bigScope" fully includes the "smallScope".
function scopeIncludes(smallScope, bigScope) {
const smallScopeParts = smallScope.split(/\s/);
const bigScopeParts = bigScope.split(/\s/);
return bigScopeParts.includes('*') || smallScopeParts.every((part) => bigScopeParts.includes(part));
}
function returnError(res, error, errorDescription = undefined, status = 400) {
return res
.status(status)
.json({ error, ...(errorDescription != null ? { error_description: errorDescription } : {}) });
}
function redirectWithParams(res, redirectUri, params) {
const targetUrl = new URL(redirectUri);
for (const [key, value] of Object.entries(params)) {
targetUrl.searchParams.append(key, value);
}
return res.redirect(targetUrl.toString());
}
const ALL_SCOPE_NAMES = `
tracks.create
tracks.update
tracks.list
tracks.show
tracks.delete
users.update
users.show
tracks.comments.create
tracks.comments.update
tracks.comments.list
tracks.comments.show
`.split(/\s/);
function isValidScope(scope) {
return scope === '*' || scopeIncludes(scope, ALL_SCOPE_NAMES.join(' '));
}
router.use((req, res, next) => {
res.locals.user = req.user;
res.locals.mainFrontendUrl = config.mainFrontendUrl;
res.locals.imprintUrl = config.imprintUrl;
res.locals.privacyPolicyUrl = config.privacyPolicyUrl;
res.locals.baseUrl = baseUrl + '/';
next();
});
router.post(
'/login',
passport.authenticate('usernameAndPasswordSession', { session: true }),
(err, req, res, next) => {
if (!err) {
next();
}
if (err.message === 'invalid credentials') {
return res.render('login', { badCredentials: true });
}
let description = 'Unknown error while processing your login.';
if (err.message === 'email not verified') {
description = 'Your account is not yet verified, please check your email or start the password recovery.';
}
return res.render('message', { type: 'negative', title: 'Login failed', description });
},
wrapRoute((req, res, next) => {
if (!req.user) {
return res.redirect(baseUrl + '/login');
}
if (req.session.next) {
res.redirect(baseUrl + req.session.next);
req.session.next = null;
return;
}
return res.render('message', { type: 'positive', title: 'You are logged in.', showFrontendLink: true });
}),
);
router.get(
'/login',
wrapRoute(async (req, res) => {
if (req.user) {
return res.render('message', { type: 'positive', title: 'You are already logged in.' });
}
return res.render('login');
}),
);
router
.route('/logout')
.post(
auth.usernameAndPasswordSession,
wrapRoute(async (req, res) => {
req.logout();
return res.redirect(baseUrl + '/login');
}),
)
.get((req, res) => {
if (req.query.redirectTo) {
req.logout();
return res.redirect(req.query.redirectTo);
}
return res.render('logout')
});
const isIp = (ip) =>
typeof ip === 'string' &&
/^([0-9]{1,3}\.)[0-9]{1,3}$/.test(ip) &&
ip
.split('.')
.every(
(num, idx) =>
!num.startsWith('0') && Number(num) > (idx === 0 || idx === 3 ? 1 : 0) && Number(num) < (idx === 3 ? 254 : 255),
);
const isLocalIp = (ip) => isIp(ip) && (ip.startsWith('10.') || ip.startsWith('172.16.') || ip.startsWith('192.168.'));
const isValidRedirectUriFor = (redirectUri) => (redirectUriPattern) => {
// Here we have an exception to the security requirements demanded by
// https://tools.ietf.org/html/draft-ietf-oauth-security-topics-16#section-2.1,
// namely, that we do not always require fully specified redirect URIs. This
// is because we cannot know beforehand which IP the OBS will be running at.
// But since it is usually accessed via local IP, we can allow all local IP
// ranges. This special case must only be used in clients that have a very
// restricted `maxScope` as well, to prevent misuse should an attack through
// this be successful.
// This special case does however enforce TLS ("https://"), for it prevents
// usage in a non-TLS-secured web server. At least passive sniffing of the
// token is not possible then. A self-signed and manually verified
// certificate should be used for this (though usually we cannot enforce the
// actual verification).
if (redirectUriPattern === '__LOCAL__') {
const url = new URL(redirectUri);
if (url.protocol === 'https:' && isLocalIp(url.host) && !url.search && !url.hash) {
return true;
}
return false;
} else {
return redirectUriPattern === redirectUri;
}
};
router.get(
'/authorize',
passport.authenticate('session'),
wrapRoute(async (req, res) => {
if (!req.user) {
req.session.next = req.url;
return res.redirect(baseUrl + '/login');
}
try {
const {
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope = '*', // fallback to "all" scope
// for PKCE
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = req.query;
// 1. Find our client and check if it exists
if (!clientId) {
return returnError(res, 'invalid_request', 'client_id parameter required');
}
const client = config.oAuth2Clients.find((c) => c.clientId === clientId);
if (!client) {
return returnError(res, 'invalid_client', 'unknown client');
}
// 2. Check that we have a redirect_uri. In addition to [RFC6749] we
// *always* require a redirect_uri.
if (!redirectUri) {
return returnError(res, 'invalid_request', 'redirect_uri parameter required');
}
// We enforce that the redirectUri exactly matches one of the provided URIs
const check = isValidRedirectUriFor(redirectUri);
if (!client.validRedirectUris.some(check)) {
return returnError(res, 'invalid_request', 'invalid redirect_uri');
}
// 3. Find out which type of response to use. [RFC6749] requires one of
// "code" or "token", but "token" is implicit grant and we do not support
// that.
if (responseType !== 'code') {
return redirectWithParams(res, redirectUri, {
error: 'unsupported_grant_type',
error_description: 'only authorization code flow with PKCE is supported by this server',
});
}
// 4. Verify we're using PKCE with supported (S256) code_challenge_method.
if (!codeChallenge) {
return redirectWithParams(res, redirectUri, {
error: 'invalid_request',
error_description: 'a code_challenge for PKCE is required',
});
}
if (codeChallengeMethod !== 'S256') {
return redirectWithParams(res, redirectUri, {
error: 'invalid_request',
error_description: 'the code_challenge_method for PKCE must be "S256"',
});
}
// 5. Get the scope.
if (!isValidScope(scope)) {
return redirectWithParams(res, redirectUri, {
error: 'invalid_scope',
error_description: 'the requested scope is not known',
});
}
if (client.maxScope && !scopeIncludes(scope, client.maxScope)) {
return redirectWithParams(res, redirectUri, {
error: 'access_denied',
error_description: 'the requested scope is not valid for this client',
});
}
// Ok, let's save all this in the session, and show a dialog for the
// decision to the user.
//
if (client.autoAccept) {
const code = AuthorizationCode.generate({
clientId,
user: req.user,
redirectUri,
scope,
codeChallenge,
});
await code.save();
return redirectWithParams(res, redirectUri, { code: code.code, scope });
} else {
req.session.authorizationTransaction = {
responseType,
clientId,
redirectUri,
scope,
expiresAt: new Date().getTime() + 1000 * 60 * 2, // 2 minute decision time
codeChallenge,
};
res.render('authorize', { clientTitle: client.title, scope, redirectUri });
}
} catch (err) {
res.status(400).json({ error: 'invalid_request', error_description: 'unknown error' });
}
}),
);
router.post(
['/authorize/approve', '/authorize/decline'],
passport.authenticate('session'),
wrapRoute(async (req, res) => {
if (!req.session.authorizationTransaction) {
return res.sendStatus(400);
}
if (!req.user) {
return res.sendStatus(400);
}
const { clientId, redirectUri, scope, expiresAt, codeChallenge } = req.session.authorizationTransaction;
if (expiresAt < new Date().getTime()) {
return res.status(400).render('message', {
type: 'negative',
title: 'Expired',
description: 'Your authorization has expired. Please go back and retry the process.',
});
}
// invalidate the transaction
req.session.authorizationTransaction = null;
if (req.path === '/authorize/approve') {
const code = AuthorizationCode.generate({
clientId,
user: req.user,
redirectUri,
scope,
codeChallenge,
});
await code.save();
return redirectWithParams(res, redirectUri, { code: code.code, scope });
} else {
return redirectWithParams(res, redirectUri, { error: 'access_denied' });
}
}),
);
/**
* This function is called when the client presents an authorization code
* (generated above) and wants it turned into an access (and possibly refresh)
* token.
*/
router.get(
'/token',
wrapRoute(async (req, res) => {
const {
grant_type: grantType,
code,
client_id: clientId,
redirect_uri: redirectUri,
// for PKCE
code_verifier: codeVerifier,
} = req.query;
if (!grantType || grantType !== 'authorization_code') {
return returnError(
res,
'unsupported_grant_type',
'only authorization code flow with PKCE is supported by this server',
);
}
if (!code) {
return returnError(res, 'invalid_request', 'code parameter required');
}
// Call this function to destroy the authorization code (if it exists),
// invalidating it when a single failed request has been received. The
// whole process must be restarted. No trial and error ;)
const destroyAuthCode = async () => {
await AuthorizationCode.deleteOne({ code });
};
if (!clientId) {
await destroyAuthCode();
return returnError(res, 'invalid_client', 'client_id parameter required');
}
if (!redirectUri) {
await destroyAuthCode();
return returnError(res, 'invalid_request', 'redirect_uri parameter required');
}
if (!codeVerifier) {
await destroyAuthCode();
return returnError(res, 'invalid_request', 'code_verifier parameter required');
}
const client = config.oAuth2Clients.find((c) => c.clientId === clientId);
if (!client) {
await destroyAuthCode();
return returnError(res, 'invalid_client', 'invalid client_id');
}
const authorizationCode = await AuthorizationCode.findOne({ code });
if (!authorizationCode) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (authorizationCode.redirectUri !== redirectUri) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (authorizationCode.expiresAt <= new Date().getTime()) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (clientId !== authorizationCode.clientId) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
if (createChallenge(codeVerifier) !== authorizationCode.codeChallenge) {
await destroyAuthCode();
return returnError(res, 'invalid_grant', 'invalid authorization code');
}
// invalidate auth code now, before generating tokens
await AuthorizationCode.deleteOne({ _id: authorizationCode._id });
const accessToken = AccessToken.generate({
clientId: authorizationCode.clientId,
user: authorizationCode.user,
scope: authorizationCode.scope,
});
await accessToken.save();
let refreshToken;
if (client.refreshTokenExpirySeconds != null) {
refreshToken = RefreshToken.generate(
{
clientId: authorizationCode.clientId,
user: authorizationCode.user,
scope: authorizationCode.scope,
},
client.refreshTokenExpirySeconds,
);
await refreshToken.save();
}
return res.json({
access_token: accessToken.token,
token_type: 'Bearer',
expires_in: Math.round((accessToken.expiresAt - new Date().getTime()) / 1000),
scope: accessToken.scope,
...(refreshToken != null ? { refresh_token: refreshToken.token } : {}),
});
}),
);
/**
* Metadata endpoint to inform clients about authorization server capabilities,
* according to https://tools.ietf.org/html/rfc8414.
*/
router.get(
'/.well-known/oauth-authorization-server',
wrapRoute(async (req, res) => {
return res.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
token_endpoint_auth_methods_supported: ['none'], // only public clients
userinfo_endpoint: `${baseUrl}/api/user`,
// registration_endpoint: `${baseUrl}/register`, // TODO
// scopes_supported: ALL_SCOPE_NAMES, // TODO
response_types_supported: ['code'], // only auth code, no implicit flow or
service_documentation: 'https://github.com/openbikesensor/portal',
ui_locales_supported: ['en-US', 'en-GB', 'en-CA', 'fr-FR', 'fr-CA'],
code_challenge_methods_supported: ['S256'],
});
}),
);
module.exports = router;
const accountService = require('../accounts/account.service');
const validateRequest = require('../_middleware/validate-request');
const Joi = require('joi');
router
.route('/register')
.post(
validateRequest(
Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
...(config.privacyPolicyUrl ? {
acceptPrivacyPolicy: Joi.boolean().truthy().required(),
} : {}),
}),
),
wrapRoute(async (req, res) => {
await accountService.register(req.body);
return res.render('message', {
type: 'positive',
title: 'Registration successful',
description: 'Please check your email for verification instructions.',
});
}),
)
.get((req, res) => res.render('register'));
router.get(
'/verify-email',
validateRequest(
Joi.object({
token: Joi.string().required(),
}),
'query',
),
wrapRoute(async (req, res) => {
await accountService.verifyEmail(req.query);
return res.render('message', {
type: 'positive',
title: 'Verification successful',
description: 'You can now log in.',
showLoginButton: true,
});
}),
);
router
.route('/forgot-password')
.post(
validateRequest(
Joi.object({
email: Joi.string().email().required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.forgotPassword(req.body);
res.render('message', {
type: 'positive',
title: 'Recovery mail sent',
description: 'Please check your inbox for password recovery instructions.',
});
}),
)
.get((req, res) => res.render('forgot-password'));
router
.route('/reset-password')
.post(
validateRequest(
Joi.object({
token: Joi.string().required(),
password: Joi.string().min(6).required(),
confirmPassword: Joi.string().valid(Joi.ref('password')).required(),
}),
),
wrapRoute(async (req, res) => {
await accountService.resetPassword(req.body);
return res.render('message', {
type: 'positive',
title: 'Password reset successful',
description: 'You can now log in.',
showLoginButton: true,
});
}),
)
.get(
validateRequest(
Joi.object({
token: Joi.string().required(),
}),
'query',
),
wrapRoute(async (req, res) => {
const { token } = req.query;
await accountService.validateResetToken({ token });
res.render('reset-password', { token });
}),
);
module.exports = router;

View file

@ -1,8 +0,0 @@
const router = require('express').Router();
router.use('/api', require('./api'));
// no prefix
router.use(require('./auth'));
module.exports = router;

View file

@ -1,137 +0,0 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const queue = require('./queue');
require('./db');
const { Track } = require('./models');
const { API_ROOT_DIR, PROCESSING_DIR, OBS_FACE_CACHE_DIR, PROCESSING_OUTPUT_DIR } = require('./paths');
const config = require('./config');
queue.process('processTrack', async (job) => {
const track = await Track.findById(job.data.trackId);
if (!track) {
throw new Error('Cannot find track to process');
}
if (track.processingJobId !== job.id) {
throw new Error('Track is processed by another job');
}
if (track.processingJobId !== job.id) {
throw new Error('Track is processed by another job');
}
if (track.processingStatus !== 'pending') {
throw new Error('Track is not pending processing');
}
try {
const { filePath } = track;
console.log('Will process track', filePath);
track.processingStatus = 'processing';
track.processingLog = '';
await track.save();
// Create input directory
const inputDirectory = path.join(PROCESSING_DIR, filePath);
await fs.promises.mkdir(inputDirectory, { recursive: true });
// copy original file to processing dir
const inputFilePath = path.join(inputDirectory, 'track.csv');
const originalFilePath = track.getOriginalFilePath();
console.log(`[${track.slug}] Copy ${originalFilePath} to ${inputFilePath}`);
await fs.promises.copyFile(originalFilePath, inputFilePath);
// create track settings file
const settingsFilePath = path.join(inputDirectory, 'track-settings.json');
console.log(`[${track.slug}] Create settings at ${settingsFilePath}`);
const settings = {
trackId: String(track._id),
settingsGeneratedAt: new Date().getTime(),
filters: [
// TODO: Add actual privacy zones from user database
/* {
type: 'PrivacyZonesFilter',
config: { privacyZones: [{ longitude: 10, latitude: 10, radius: 250 }] },
}, */
],
};
await fs.promises.writeFile(settingsFilePath, JSON.stringify(settings));
// Create output directory
const outputDirectory = path.join(PROCESSING_OUTPUT_DIR, filePath);
await fs.promises.mkdir(outputDirectory, { recursive: true });
const stdoutFile = path.join(outputDirectory, 'stdout.log');
const stderrFile = path.join(outputDirectory, 'stderr.log');
const stdout = fs.createWriteStream(stdoutFile);
const stderr = fs.createWriteStream(stderrFile);
// TODO: Generate track transformation settings (privacy zones etc)
// const settingsFilePath = path.join(inputDirectory, 'track-settings.json');
const child = spawn(
'python3',
[
path.join(API_ROOT_DIR, 'tools', 'process_track.py'),
'--input',
inputFilePath,
'--output',
outputDirectory,
'--path-cache',
OBS_FACE_CACHE_DIR,
'--settings',
settingsFilePath,
// '--anonymize-user-id', 'remove',
// '--anonymize-measurement-id', 'remove',
],
{
cwd: PROCESSING_DIR,
env: {
POSTGRES_URL: config.postgres.url,
},
},
);
child.stdout.pipe(process.stdout);
child.stdout.pipe(stdout);
child.stderr.pipe(process.stderr);
child.stderr.pipe(stderr);
const code = await new Promise((resolve) => child.on('close', resolve));
track.processingLog += (
await Promise.all([
fs.promises.readFile(stdoutFile),
fs.promises.readFile(stderrFile),
// split lines
])
)
.join('\n')
.trim();
if (code !== 0) {
throw new Error(`Track processing failed with status ${code}`);
}
// Read some results back into the database for quick access and
// accumulation
const statisticsContent = await fs.promises.readFile(path.join(outputDirectory, 'statistics.json'));
track.statistics = JSON.parse(statisticsContent);
track.processingStatus = 'complete';
await track.save();
// Maybe we have found out the recording date, regenerate the automatic
// title (if not yet manually set)
await track.autoGenerateTitle();
} catch (err) {
console.error('Processing failed:', err);
track.processingLog += String(err) + '\n' + err.stack + '\n';
track.processingStatus = 'error';
await track.save();
}
});
console.log('Worker started.');

View file

@ -1,17 +0,0 @@
extends layout.pug
block title
| Authorize #{clientTitle}
block content
p.
You are about to confirm a login to client #[b= clientTitle]. You have 2
minutes time for your decision.
.ui.grid.two.columns
form.column(method="post", action="authorize/decline")
button.ui.button.fluid.red(type="submit", class="red") Decline
form.column(method="post", action="authorize/approve")
button.ui.button.fluid.green(type="submit", class="green") Approve

View file

@ -1,12 +0,0 @@
extends layout.pug
block title
| Reset Password
block content
form.ui.form(method="post")
.field
label(for="email") E-Mail Address
input(id="email", name="email")
button.ui.button.primary(type="submit") Send recovery mail

View file

@ -1,34 +0,0 @@
html
head
base(href=baseUrl)
title
block title
| Authorization Server
| - OpenBikeSensor Account
link(rel="stylesheet", href="semantic-ui/semantic.min.css")
style.
body > main > header {
margin: 16px 0;
}
body
main.ui.container
header
nav.ui.menu
a.item(href=mainFrontendUrl, title="Back to Portal")
i.icon.arrow.left
.header.item OpenBikeSensor Account
.right.menu
if mainFrontendUrl
if !user
a.item(href="login") Login
a.item(href="register") Register
if user
a.item(href="logout") Logout
div.ui.grid.centered
div.eight.wide.column
h2.ui.header
block title
block content

View file

@ -1,20 +0,0 @@
extends layout.pug
block title
| Login
block content
form.ui.form(method="post")
.field
label(for="email") E-Mail Address
input(id="email", name="email")
.field
label(for="password") Password
input(name="password", type="password")
if badCredentials
p.ui.negative.message Invalid login credentials, please try again.
button.ui.button.primary(type="submit", style="margin-right: 2rem") Login
a.ui(href="forgot-password") I forgot my password

View file

@ -1,8 +0,0 @@
extends layout.pug
block title
| Logout
block content
form(method="post", action="logout")
button.ui.button(type="submit") Log out now

View file

@ -1,14 +0,0 @@
extends layout.pug
block title
= title
block content
if description
div(class="ui message " + type)= description
if showLoginButton
p: a(href="login") Go to login
if showFrontendLink
p: a(href=mainFrontendUrl) Back to Portal

View file

@ -1,38 +0,0 @@
extends layout.pug
block title
| Register
block content
form.ui.form(method="post")
.field
label(for="username") Username
input(id="username", name="username")
p.form-hint At least 3 characters, alphanumerical, must be unique
.field
label(for="email") E-Mail Address
input(id="email", name="email", type="email")
.field
label(for="password") Password
input(id="password", name="password", type="password")
.field
label(for="confirmPassword") Confirm Password
input(id="confirmPassword", name="confirmPassword", type="password")
if privacyPolicyUrl
.field
label Privacy policy
.ui.checkbox
input(id="acceptPrivacyPolicy", name="acceptPrivacyPolicy", type="checkbox", value="true")
label(for="acceptPrivacyPolicy")
| I have read and understood the
|
a(href=privacyPolicyUrl, target="_blank", rel="noreferrer") privacy policy
|
| and consent to the use of my data as described therein.
button.ui.button.primary(type="submit") Register

View file

@ -1,18 +0,0 @@
extends layout.pug
block title
| Reset Password
block content
form.ui.form(method="post")
input(type="hidden", name="token", value=token)
.field
label(for="password") New Password
input(id="password", name="password", type="password")
.field
label(for="confirmPassword") Confirm Password
input(id="confirmPassword", name="confirmPassword", type="password")
button.ui.button.primary(type="submit") Set new password