1 Commits

Author SHA1 Message Date
daa7209742 Switch from RabbitMQ to server-sent events
(not fully working yet)
2021-05-03 20:58:40 +02:00
38 changed files with 22158 additions and 672 deletions

1
.gitignore vendored
View File

@@ -107,3 +107,4 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
android_new

View File

@@ -44,7 +44,8 @@ dependencies {
implementation 'com.squareup.moshi:moshi:1.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.11.0'
implementation 'com.rabbitmq:amqp-client:5.9.0'
implementation "com.squareup.okhttp3:okhttp:4.9.0"
implementation 'com.squareup.okhttp3:okhttp:4.9.0'
implementation 'com.squareup.okhttp3:okhttp-sse:4.9.1'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

View File

@@ -0,0 +1,90 @@
package de.nicolasklier.livebeat
import android.util.Log
import okhttp3.Response
import okhttp3.internal.platform.Platform
import okhttp3.internal.platform.Platform.Companion.INFO
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okio.IOException
import org.jetbrains.annotations.Nullable
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingDeque
import java.util.concurrent.TimeUnit
class EventSourceRecorder : EventSourceListener() {
private val events: BlockingQueue<Any> = LinkedBlockingDeque()
override fun onOpen(eventSource: EventSource, response: Response) {
Log.i("SSE", "Connection opened!")
}
override fun onEvent(
eventSource: EventSource, @Nullable id: String?, @Nullable type: String?,
data: String
) {
Log.i("SSE", "onEvent: " + data)
}
override fun onClosed(eventSource: EventSource) {
Log.i("SSE", "Connection to SSE got closed!")
events.add(Closed())
}
fun onFailure(
eventSource: EventSource?,
@Nullable t: Throwable
) {
Platform.get().log("[ES] onFailure", INFO, t)
}
private fun nextEvent(): Any {
return try {
val event: Any = events.poll(10, TimeUnit.SECONDS)
?: throw AssertionError("Timed out waiting for event.")
event
} catch (e: InterruptedException) {
throw AssertionError(e)
}
}
internal class Open(val eventSource: EventSource?, response: Response) {
val response: Response
override fun toString(): String {
return "Open[$response]"
}
init {
this.response = response
}
}
internal class Failure(val t: Throwable, response: Response?) {
val response: Response?
val responseBody: String?
override fun toString(): String {
return if (response == null) {
"Failure[$t]"
} else "Failure[$response]"
}
init {
this.response = response
var responseBody: String? = null
if (response != null) {
try {
responseBody = response.body.toString()
} catch (ignored: IOException) {
}
}
this.responseBody = responseBody
}
}
internal class Closed {
override fun toString(): String {
return "Closed[]"
}
}
}

View File

@@ -0,0 +1,58 @@
package de.nicolasklier.livebeat
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSources
import java.lang.Error
import java.net.ConnectException
class HttpRequests {
companion object {
fun get(url: String, sendToken: Boolean = true): Response {
val client = OkHttpClient()
var req = Request.Builder()
.url(url)
.get()
.build()
if (sendToken) {
req = req.newBuilder().addHeader("token", MainActivity.TOKEN).build();
}
return client.newCall(req).execute();
}
fun post(url: String, body: String, sendToken: Boolean = true): Response {
try {
val client = OkHttpClient()
var req = Request.Builder()
.url(url)
.post(
(body).toRequestBody()
)
.header("Content-Type", "application/json")
.build()
if (sendToken) {
req = req.newBuilder().addHeader("token", MainActivity.TOKEN).build()
}
return client.newCall(req).execute()
} catch (e: ConnectException) {
throw Error("Connection to $url couldn't be made: ${e.message}")
}
}
fun sse(url: String) {
val client = OkHttpClient()
val req = Request.Builder().url(url).build();
val handler = EventSourceRecorder()
val factory = EventSources.createFactory(client)
val sse = factory.newEventSource(req, handler)
sse.request()
}
}
}

View File

@@ -20,6 +20,7 @@ import android.view.MenuItem
import android.view.View
import android.widget.Button
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@@ -31,6 +32,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.net.ConnectException
import java.util.logging.Logger
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
@@ -49,13 +51,13 @@ class MainActivity : AppCompatActivity() {
if (TOKEN == "") return;
Thread(Runnable {
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
val client = OkHttpClient()
/*val client = OkHttpClient()
val req = Request.Builder()
.url("$API_URL/phone/$androidId")
.header("token", TOKEN)
.get()
.build()
val response = client.newCall(req).execute()
.build()*/
val response = HttpRequests.get("$API_URL/phone/$androidId")
if (response.code != 200) {
Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Device isn't registered yet. Registering ...", Snackbar.LENGTH_SHORT)
@@ -75,7 +77,7 @@ class MainActivity : AppCompatActivity() {
val phoneToJson = moshi.adapter(Phone::class.java)
val json = phoneToJson.toJson(phone)
val createPhone = Request.Builder()
/*val createPhone = Request.Builder()
.url("$API_URL/phone")
.post(
(json).toRequestBody()
@@ -83,7 +85,8 @@ class MainActivity : AppCompatActivity() {
.header("Content-Type", "application/json")
.header("token", TOKEN)
.build()
client.newCall(createPhone).execute()
client.newCall(createPhone).execute()*/
HttpRequests.post("$API_URL/phone", json);
}
}).start()
}
@@ -95,7 +98,7 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main)
setSupportActionBar(findViewById(R.id.toolbar))
val process = Runtime.getRuntime().exec("su")
//val process = Runtime.getRuntime().exec("su")
// Check authorization
val backendChecks = Thread(Runnable {
@@ -104,15 +107,40 @@ class MainActivity : AppCompatActivity() {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val jsonToLogin = moshi.adapter(Login::class.java)
val token = "{ \"username\": \"" + username + "\"," +
"\"password\": \"" + password + "\" }"
try {
HttpRequests.post("$API_URL/user/login", token)
} catch (e: Error) {
Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Backend server is not available", Snackbar.LENGTH_SHORT)
.setBackgroundTint(Color.RED)
.setActionTextColor(Color.WHITE)
.show();
return@Runnable
}
val client = OkHttpClient()
val req = Request.Builder()
.url("$API_URL/user/login")
.post(
("{ \"username\": \"" + username + "\"," +
"\"password\": \"" + password + "\" }").toRequestBody()
(token).toRequestBody()
)
.header("Content-Type", "application/json")
.build()
// Check if server is available.
try {
val testReq = Request.Builder()
.url("$API_URL/user/login")
.post(
("{ \"username\": \"" + username + "\"," +
"\"password\": \"" + password + "\" }").toRequestBody()
)
.header("Content-Type", "application/json")
.build()
client.newCall(testReq).execute();
} catch (e: ConnectException) {
}
val loginResponse = client.newCall(req).execute()
val responseBody = loginResponse.body!!.string()
@@ -141,8 +169,10 @@ class MainActivity : AppCompatActivity() {
val userInfoResponseBody = userinfoResponse.body!!.string()
USER = jsonToUser.fromJson(userInfoResponseBody)
val intent = Intent(this, TrackerService::class.java)
// Only start service if authentication went good.
startService(Intent(this, TrackerService::class.java))
startService(intent)
Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Login succeeded", Snackbar.LENGTH_SHORT)
.setBackgroundTint(Color.GREEN)
@@ -171,17 +201,8 @@ class MainActivity : AppCompatActivity() {
this.broadcastReceiver = object : BroadcastReceiver() {
@SuppressLint("CutPasteId")
override fun onReceive(context: Context, intent: Intent) {
val statusRabbit = intent.getBooleanExtra("statusRabbit", false)
val statusHttp = intent.getIntExtra("statusHttp", 404)
if (statusRabbit) {
findViewById<TextView>(R.id.rabbitStatus).text = "connected"
findViewById<TextView>(R.id.rabbitStatus).setTextColor(Color.GREEN)
} else {
findViewById<TextView>(R.id.rabbitStatus).text = "disconnected"
findViewById<TextView>(R.id.rabbitStatus).setTextColor(Color.RED)
}
/*if (statusHttp == 200) {
findViewById<TextView>(R.id.httpStatus).text = "ONLINE (no login)"
findViewById<TextView>(R.id.httpStatus).setTextColor(Color.CYAN)

View File

@@ -27,7 +27,7 @@ class User(
val type: String,
val lastLogin: String,
val twoFASecret: String?,
val brokerToken: String,
val eventToken: String,
val createdAt: String
)

View File

@@ -10,7 +10,6 @@ import android.content.pm.PackageManager
import android.graphics.Color
import android.location.LocationManager
import android.os.BatteryManager
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
@@ -18,14 +17,9 @@ import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import com.rabbitmq.client.Channel
import com.rabbitmq.client.Connection
import com.rabbitmq.client.ConnectionFactory
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.util.concurrent.TimeoutException
class TrackerService : Service() {
@@ -36,38 +30,9 @@ class TrackerService : Service() {
return null
}
private fun connectWithBroker() {
// This thread only connects to RabbitMQ
val connectionThread = Thread(Runnable {
val client = OkHttpClient()
val factory = ConnectionFactory()
factory.username = MainActivity.USER!!.name
factory.password = MainActivity.USER!!.brokerToken
factory.virtualHost = "/"
factory.host = "192.168.178.26"
factory.port = 5672
factory.isAutomaticRecoveryEnabled = true
try {
conn[0] = factory.newConnection()
channel[0] = conn[0]?.createChannel()
val intent = Intent("de.nicolasklier.livebeat")
val bundle = Bundle()
bundle.putBoolean("statusRabbit", true)
intent.putExtras(bundle)
this.sendBroadcast(intent)
channel[0]?.queueDeclare("tracker-" + factory.username, true, false, false, null)
//channel[0]?.basicPublish("", "Tracker", null, "Test message".toByteArray())
Log.i("RabbitMQ", "run: Published test message")
} catch (e: IOException) {
e.printStackTrace()
} catch (e: TimeoutException) {
e.printStackTrace()
}
})
connectionThread.start()
@SuppressLint("CheckResult")
private fun subscribeToEvents() {
HttpRequests.sse("http://192.168.178.26/user/events?token=${MainActivity.USER?.eventToken}")
}
@SuppressLint("HardwareIds")
@@ -105,7 +70,7 @@ class TrackerService : Service() {
}
}
connectWithBroker()
subscribeToEvents()
startForeground()
return super.onStartCommand(intent, flags, startId)
}

View File

@@ -68,31 +68,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal">
<TextView
android:id="@+id/rabbitText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|center_vertical"
android:layout_weight="0"
android:text="RabbitMQ"
android:textColor="@color/white" />
<TextView
android:id="@+id/rabbitStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="20dp"
android:layout_weight="0"
android:fontFamily="sans-serif-black"
android:text="disconnected"
android:textAlignment="center"
android:textAllCaps="true"
android:textColor="@color/orange"
android:textSize="14sp" />
</LinearLayout>
android:orientation="horizontal"/>
<Space
android:layout_width="match_parent"

8
backend/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
backend/.idea/backend.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
backend/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8" project-jdk-type="Python SDK" />
</project>

8
backend/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>

6
backend/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -7,13 +7,13 @@ import * as figlet from 'figlet';
import * as mongoose from 'mongoose';
import { exit } from 'process';
import * as winston from 'winston';
import { config } from './config';
import { GetBeat, GetBeatStats } from './endpoints/beat';
import { getNotification } from './endpoints/notification';
import { GetPhone, PostPhone } from './endpoints/phone';
import { DeleteUser, GetUser, LoginRabbitUser, LoginUser, MW_User, PatchUser, PostUser, Resource, Topic, VHost } from './endpoints/user';
import { DeleteUser, GetUser, LoginUser, MW_User, PatchUser, PostUser, UserEvents } from './endpoints/user';
import { hashPassword, randomPepper, randomString } from './lib/crypto';
import { EventManager } from './lib/eventManager';
import { RabbitMQ } from './lib/rabbit';
import { UserType } from './models/user/user.interface';
import { User } from './models/user/user.model';
@@ -28,6 +28,7 @@ export const IS_DEBUG = process.env.DEBUG == 'true';
export let logger: winston.Logger;
export let rabbitmq: RabbitMQ;
export let eventManager: EventManager = new EventManager();
async function run() {
const { combine, timestamp, label, printf, prettyPrint } = winston.format;
@@ -108,7 +109,7 @@ async function run() {
await User.create({
name: 'admin',
password: await hashPassword(randomPassword + salt + randomPepper()),
brokerToken: randomString(16),
eventToken: randomString(16),
salt,
createdAt: Date.now(),
lastLogin: 0,
@@ -139,15 +140,12 @@ async function run() {
app.get('/', (req, res) => res.status(200).send('OK'));
// User authentication
// User authentication & actions
app.post('/user/login', (req, res) => LoginUser(req, res));
app.get('/user/rabbitlogin', (req, res) => LoginRabbitUser(req, res));
app.get('/user/vhost', (req, res) => VHost(req, res));
app.get('/user/resource', (req, res) => Resource(req, res));
app.get('/user/topic', (req, res) => Topic(req, res));
// CRUD user
app.get('/user/notification', MW_User, (req, res) => getNotification(req, res)); // Notifications
app.get('/user/events', (req, res) => UserEvents(req, res));
app.get('/user/', MW_User, (req, res) => GetUser(req, res));
app.post('/user/', MW_User, (req, res) => PostUser(req, res));
app.get('/user/:id', MW_User, (req, res) => GetUser(req, res));
@@ -171,8 +169,8 @@ async function run() {
* Message broker
*/
rabbitmq = new RabbitMQ();
await rabbitmq.init();
logger.info("Connected with message broker.");
//await rabbitmq.init();
//logger.info("Connected with message broker.");
}
run();

View File

@@ -16,6 +16,10 @@ export const config: IConfig = {
host: "0.0.0.0"
}
}
/**
* END OF CONFIG
* ====================
*/
export interface IConfig {
authentification: {

View File

@@ -1,9 +1,13 @@
import { Response } from "express";
import { eventManager, logger } from "../app";
import { LivebeatRequest } from "../lib/request";
import { IBeat } from "../models/beat/beat.interface";
import { Beat } from "../models/beat/beat.model.";
import { ISeverity } from "../models/notifications/notification.interface";
import { Phone } from "../models/phone/phone.model";
const timeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
export async function GetBeatStats(req: LivebeatRequest, res: Response) {
const phones = await Phone.find({ user: req.user?._id });
const perPhone: any = {};
@@ -20,23 +24,26 @@ export async function GetBeatStats(req: LivebeatRequest, res: Response) {
}
export async function GetBeat(req: LivebeatRequest, res: Response) {
const from: number = Number(req.query.from);
const to: number = Number(req.query.to);
const from: number = Number(req.query.from || 0);
const to: number = Number(req.query.to || Date.now() / 1000);
const limit: number = Number(req.query.limit || 10000);
const sort: number = Number(req.query.sort || 1); // Either -1 or 1
const phoneId = req.query.phoneId;
// Grab default phone if non was provided.
const phone = req.query.phone === undefined ? await Phone.findOne({ user: req.user?._id }) : await Phone.findOne({ _id: phoneId, user: req.user?._id });
let beats: IBeat[] = []
let beats: IBeat[] = [];
//console.log(from, to);
//console.log(`Search from ${new Date(from).toString()} to ${new Date(to * 1000).toString()}`);
if (phone !== null) {
beats = await Beat.find(
{
phone: phone._id,
createdAt: {
$gte: new Date((from | 0) * 1000),
$lte: new Date((to | Date.now() /1000) * 1000)
$gte: new Date((from)),
$lte: new Date(to * 1000)
}
}).sort({ _id: sort }).limit(limit);
res.status(200).send(beats);
@@ -44,3 +51,61 @@ export async function GetBeat(req: LivebeatRequest, res: Response) {
res.status(404).send({ message: 'Phone not found' });
}
}
export async function AddBeat(req: LivebeatRequest, res: Response) {
const beat = req.body as IBeat;
const androidId = req.headers.deviceId as string;
if (androidId === undefined) {
res.status(401).send({ message: 'Device id is missing' });
}
// Get phone
const phone = await Phone.findOne({ androidId });
if (phone == undefined) {
logger.warning(`Received beat from unknown device with id ${androidId}`);
return;
}
let newBeat;
if (beat.coordinate !== undefined && beat.accuracy !== undefined) {
logger.info(`New beat from ${phone.displayName} => ${beat.coordinate[0]}, ${beat.coordinate[1]} | Height: ${beat.coordinate[3]}m | Speed: ${beat.coordinate[4]} | Accuracy: ${beat.accuracy}% | Battery: ${beat.battery}%`);
newBeat = await Beat.create({
phone: phone._id,
// [latitude, longitude, altitude]
coordinate: [beat.coordinate[0], beat.coordinate[1], beat.coordinate[2]],
accuracy: beat.coordinate[3],
speed: beat.coordinate[4],
battery: beat.battery,
createdAt: Date.now()
});
}
newBeat = await Beat.create({
phone: phone._id,
battery: beat.battery,
createdAt: Date.now()
});
// Broadcast if device became active
if (timeouts.has(phone.id)) {
clearTimeout(timeouts.get(phone.id)!!);
} else {
phone.active = true;
await phone.save();
eventManager.push('phone_alive', phone.toJSON(), phone.user);
}
const timeoutTimer = setTimeout(async () => {
eventManager.push('phone_dead', phone.toJSON(), phone.user, ISeverity.WARN);
timeouts.delete(phone.id);
phone.active = false;
await phone.save();
}, 60_000);
timeouts.set(phone.id, timeoutTimer);
eventManager.push('beat', newBeat.toJSON(), phone.user);
}

View File

@@ -5,7 +5,6 @@ import { Beat } from "../models/beat/beat.model.";
import { Phone } from "../models/phone/phone.model";
export async function GetPhone(req: LivebeatRequest, res: Response) {
const phoneId: String = req.params['id'];

View File

@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { decode, sign, verify } from 'jsonwebtoken';
import { JWT_SECRET, logger, RABBITMQ_URI } from '../app';
import { eventManager, JWT_SECRET, logger, RABBITMQ_URI } from '../app';
import * as jwt from 'jsonwebtoken';
import { config } from '../config';
import { hashPassword, randomPepper, randomString, verifyPassword } from '../lib/crypto';
@@ -51,7 +51,7 @@ export async function PostUser(req: LivebeatRequest, res: Response) {
}
const salt = randomString(config.authentification.salt_length);
const brokerToken = randomString(16);
const eventToken = randomString(16);
const hashedPassword = await hashPassword(password + salt + randomPepper()).catch(error => {
res.status(400).send({ message: 'Provided password is too weak and cannot be used.' });
return;
@@ -61,7 +61,7 @@ export async function PostUser(req: LivebeatRequest, res: Response) {
name,
password: hashedPassword,
salt,
brokerToken,
eventToken,
type,
lastLogin: new Date(0)
});
@@ -72,6 +72,27 @@ export async function PostUser(req: LivebeatRequest, res: Response) {
res.status(200).send({ setupToken });
}
export async function UserEvents(req: LivebeatRequest, res: Response) {
if (req.query.token === undefined) {
res.status(401).send({ message: 'You need to define your event token.' });
return;
}
const eventToken = req.query.token as string;
const user = await User.findOne({ eventToken });
if (user === null) {
res.status(401).send({ message: 'This event token is not valid.' });
return;
}
eventManager.join(user.id, res);
}
export async function UserSubscribeEvent(req: Request, res: Response) {
}
export async function DeleteUser(req: Request, res: Response) {
}
@@ -120,159 +141,6 @@ export async function LoginUser(req: Request, res: Response) {
res.status(200).send({ token });
}
/**
* This function handles all logins to RabbitMQ since they need a differnt type of response
* then requests from frontends (web and phone).
*/
export async function LoginRabbitUser(req: Request, res: Response) {
const username = req.query.username;
const password = req.query.password;
res.status(200);
if (username === undefined || password === undefined) {
res.send('deny');
return;
}
// Check if request comes from backend. Basicly, we permitting ourself to connect with RabbitMQ.
if (username === "backend" && password === RABBITMQ_URI.split(':')[2].split('@')[0]) {
res.send('allow administrator');
return;
}
// Get user from database
const user = await User.findOne({ name: username.toString() });
// If we are here, it means we have a non-admin user.
if (user === null) {
res.send('deny');
return;
}
// Auth token for message broker is stored in plain text since it's randomly generated and only grants access to the broker.
if (user.brokerToken === password.toString()) {
if (user.type === UserType.ADMIN) {
res.send('allow administrator');
} else {
// Not an admin, grant user privilieges
res.send('allow user')
}
return;
}
res.send('deny');
}
/**
* This function basicly allows access to the root vhost if the user is known.
*/
export async function VHost(req: Request, res: Response) {
const vhost = req.query.vhost;
const username = req.query.username;
if (vhost === undefined || username === undefined) {
res.status(200).send('deny');
return;
}
if (vhost != '/') {
res.status(200).send('deny');
return;
}
// Check if user is us
if (username === 'backend') {
res.status(200).send('allow');
return;
}
const user = await User.findOne({ name: username.toString() });
if (user === null) {
// Deny if user doesn't exist.
res.status(200).send('deny');
} else {
res.status(200).send('allow');
}
}
export async function Resource(req: Request, res: Response) {
const username = req.query.username;
const vhost = req.query.vhost;
const resource = req.query.resource;
const name = req.query.name;
const permission = req.query.permission;
const tags = req.query.tags;
if (username === undefined || vhost === undefined || resource === undefined || name === undefined || permission === undefined || tags === undefined) {
res.status(200).send('deny');
return;
}
// Check if it's us
if (username.toString() == 'backend') {
res.status(200).send('allow');
return;
}
// Deny if not root vhost
if (vhost.toString() != '/') {
res.status(200).send('deny');
return;
}
// Check if user exists
const user = await User.findOne({ name: username.toString() });
if (user == null) {
res.status(200).send('deny');
return;
}
if (tags.toString() == "administrator" && user.type != UserType.ADMIN) {
res.status(200).send('deny');
return;
}
// TODO: This has to change if we want to allow users to see the realtime movement of others.
if (resource.toString().startsWith('tracker-') && resource != 'tracker-' + username) {
res.status(200).send('deny');
return;
}
res.status(200).send('allow');
}
export async function Topic(req: Request, res: Response) {
res.status(200);
const username = req.query.username;
const routingKey = req.query.routing_key;
if (routingKey === undefined || username === undefined) {
res.send('deny');
return;
}
// Check if it's us
if (username.toString() == 'backend') {
res.status(200).send('allow');
return;
}
// Check if user exists
const user = await User.findOne({ name: username.toString() });
if (user === null) {
res.send('deny');
return;
}
if (routingKey !== user.id) {
res.send('deny');
return;
}
res.status(200).send('allow');
}
/**
* This middleware validates any tokens that are required to access most of the endpoints.
* Note: This validation doesn't contain any permission checking.

170
backend/lib/eventManager.ts Normal file
View File

@@ -0,0 +1,170 @@
import { Response } from "express";
import { logger } from "../app";
import { ISeverity, NotificationType, PublicNotificationType } from "../models/notifications/notification.interface";
import { addNotification } from "../models/notifications/notification.model";
import { IPhone } from "../models/phone/phone.interface";
import { IUser } from "../models/user/user.interface";
import { User } from "../models/user/user.model";
import { randomString } from "./crypto";
/**
* This class stores one specific client.
*/
export class Client {
id: string;
userId: string;
stream: Response;
constructor(stream: Response, userId: string) {
this.id = randomString(16);
this.userId = userId;
this.stream = stream;
}
send(type: NotificationType, data: any) {
this.stream.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
}
async getUser() {
return await User.findById(this.userId);
}
}
export class Clients {
private clients: Client[];
constructor(clients: Client[]) {
this.clients = clients;
}
getClientsByUser(userId: string) {
const userClients = [];
for (let i = 0; i < this.clients.length; i++) {
if (this.clients[i].userId === userId) {
userClients.push(this.clients[i]);
}
}
return userClients;
}
closeAllClientsByUser(userId: string) {
this.getClientsByUser(userId).forEach(client => {
client.stream.end();
});
}
addClient(client: Client) {
this.clients.push(client);
return client;
}
getClients() {
return this.clients;
}
}
export class EventManager {
constructor() {
setInterval(() => {
this.broadcast('info', { message: "Test" });
}, 2000);
}
// This map stores a open data stream and it's associated room.
private clients: Clients = new Clients([]);
private addClient(stream: Response, userId: string) {
this.clients.addClient(new Client(stream, userId));
}
/**
* Add a client to a specific room
* @param room Used as an id for the specific room
* @param stream A open connection to the user
*/
async join(userId: string, stream: Response) {
if (stream.req == undefined) {
stream.send(500);
return;
}
// Check user
const user = await User.findById(userId);
if (user === null) {
stream.send(401);
return;
}
// Make sure to keep the connection open
stream.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
this.addClient(stream, userId);
logger.debug(`Client ${stream.req.hostname} of user ${user.name} joined.`);
}
/**
* Push a new event into a specific room
* @param event Type of the event
* @param data Content of the event
* @param selector Room to push in. If empty then it will be a public broadcast to anyone.
*/
push(type: NotificationType, data: any, user: IUser, severity = ISeverity.INFO) {
let clients = this.clients.getClientsByUser(user.id);
if (clients === undefined) return;
/* Manage notifications */
if (type != 'beat' && user !== undefined) {
if (type == 'phone_alive' || type == 'phone_dead') {
addNotification(type, severity, ((data as IPhone)._id), user);
}
}
data = { type, severity, ...data };
clients.forEach((client) => {
client.stream.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
});
if (user === undefined) {
logger.debug(`Broadcasted event ${type} to all users (${clients.length} clients affected)`);
} else {
logger.debug(`Broadcasted event ${type} to user ${user.id} (${clients.length} clients affected)`);
}
}
/**
* Very much like push() but it will send this message to **every connected client!**
*/
broadcast(type: PublicNotificationType, data: any) {
this.clients.getClients().forEach(async client => {
console.log(`Send ${JSON.stringify(data)} (of type ${type}) to a client of user ${(await client.getUser())?.name}`);
client.stream.write(`event: message\ndata: ${JSON.stringify(data)}\n\n`);
});
logger.debug(`Broadcasted event ${type} to all users (${this.clients.getClients().length} clients affected)`);
}
/**
* End the communication with a specific client.
*/
end(stream: Response, userId: string) {
stream.end();
logger.debug(`End connection with ${stream.req?.hostname} (user: ${userId})`);
}
static buildEventTypeName(type: EventType, user: IUser) {
return `${type}-${user}`;
}
}
export type EventType =
| 'tracker' // Receive just the gps location of a specific user.
| 'user' // Receive user updates.
| 'all'; // Receive all above events.

View File

@@ -63,7 +63,7 @@ export class RabbitMQ {
this.timeouts.delete(phone.id);
phone.active = false;
await phone.save();
}, 30_000);
}, 60_000);
this.timeouts.set(phone.id, timeoutTimer);
this.publish(phone.user.toString(), newBeat.toJSON(), 'beat');

View File

@@ -4,8 +4,8 @@ import { IPhone } from '../phone/phone.interface';
export interface IBeat extends Document {
// [latitude, longitude, altitude, accuracy, speed]
coordinate?: number[],
accuracy: number,
speed: number,
accuracy?: number,
speed?: number,
battery?: number,
phone: IPhone,
createdAt?: Date

View File

@@ -8,7 +8,8 @@ export enum ISeverity {
ERROR = 3
}
export type NotificationType = 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic';
export type NotificationType = 'beat' | 'phone_alive' | 'phone_dead' | 'phone_register' | 'panic' | 'test';
export type PublicNotificationType = 'shutdown' | 'restart' | 'warning' | 'error' | 'info';
export interface INotification extends Document {
type: NotificationType;

View File

@@ -13,6 +13,6 @@ export interface IUser extends Document {
type: UserType,
lastLogin: Date,
twoFASecret?: string,
brokerToken: string,
eventToken: string,
createdAt?: Date
}

View File

@@ -6,7 +6,7 @@ const schemaUser = new Schema({
salt: { type: String, required: true },
type: { type: String, required: true, default: 'user' }, // This could be user, admin, guest
twoFASecret: { type: String, required: false },
brokerToken: { type: String, required: true },
eventToken: { type: String, required: true },
lastLogin: { type: Date, required: true, default: Date.now },
}, {
timestamps: {

3774
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@
"@types/node": "^14.14.9",
"amqplib": "^0.6.0",
"argon2": "^0.27.0",
"bi-directional-map": "^1.0.0",
"body-parser": "^1.19.0",
"chalk": "^4.1.0",
"cors": "^2.8.5",
@@ -34,20 +35,20 @@
"winston": "^3.3.3"
},
"devDependencies": {
"@types/amqplib": "0.5.14",
"@types/argon2": "0.15.0",
"@types/body-parser": "1.19.0",
"@types/chalk": "2.2.0",
"@types/cors": "2.8.8",
"@types/dotenv": "8.2.0",
"@types/express": "4.17.8",
"@types/figlet": "1.2.0",
"@types/jsonwebtoken": "8.5.0",
"@types/moment": "2.13.0",
"@types/mongoose": "5.7.36",
"@types/typescript": "2.0.0",
"@types/winston": "2.4.4",
"concurrently": "^5.3.0",
"nodemon": "^2.0.5",
"@types/jsonwebtoken": "8.5.0",
"@types/amqplib": "0.5.14",
"@types/cors": "2.8.8",
"@types/moment": "2.13.0"
"nodemon": "^2.0.5"
}
}

17692
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -36,7 +36,6 @@
"moment": "^2.29.1",
"ng2-charts": "^2.4.2",
"ngx-mapbox-gl": "^4.8.1",
"ngx-mqtt": "^7.0.14",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.10.2"

View File

@@ -1,5 +1,4 @@
import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core';
import { resolve } from 'dns';
import { APIService, UserType } from '../api.service';
import { AlertService } from '../_alert/alert.service';

View File

@@ -1,12 +1,11 @@
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MqttService } from 'ngx-mqtt';
import { BehaviorSubject } from 'rxjs';
import * as moment from 'moment';
import { error } from 'protractor';
import { AlertService } from './_alert/alert.service';
import { AlertService, AlertType } from './_alert/alert.service';
/*
* ==========================
* DEFINITION OF TYPE
*/
export interface ILogin {
@@ -44,7 +43,7 @@ export enum UserType {
export interface IUser {
_id: string;
name: string;
brokerToken: string;
eventToken: string;
type: UserType;
lastLogin: Date;
twoFASecret?: string;
@@ -84,8 +83,13 @@ export interface INotification extends Document {
user: IUser;
}
interface CustomEvent extends Event {
data: any;
}
/*
* END OF THE DEFINITION OF TYPE
* END OF THE TYPE DEFINITION
* ==========================
*/
@Injectable({
@@ -94,9 +98,9 @@ export interface INotification extends Document {
export class APIService {
private token: string;
private events: EventSource | undefined;
username: string;
rabbitmq: any;
// Passthough data (not useful for api but a way for components to share data)
showFilter = true;
@@ -112,7 +116,7 @@ export class APIService {
user: IUser = {
_id: '',
name: '',
brokerToken: '',
eventToken: '',
lastLogin: new Date(2020, 3, 1),
type: UserType.GUEST,
createdAt: new Date(),
@@ -128,42 +132,66 @@ export class APIService {
API_ENDPOINT = 'http://192.168.178.26:8040';
constructor(private httpClient: HttpClient, private mqtt: MqttService, private alert: AlertService) { }
constructor(private httpClient: HttpClient, private alert: AlertService) { }
private mqttInit(): void {
// Connect with RabbitMQ after we received our user information
this.mqtt.connect({
hostname: '192.168.178.26',
port: 15675,
protocol: 'ws',
path: '/ws',
username: this.user.name,
password: this.user.brokerToken
});
/**
* This functions opens a new http connection that stays open, forever, to receive events from the backend.
*/
async subscribeToEvents() {
let shownError = false;
this.mqtt.observe(this.user._id).subscribe(async message => {
if (message !== undefined || message !== null) {
const obj = JSON.parse(message.payload.toString());
console.log('Received message:', obj);
// If there is already a event, close it.
if (this.events !== undefined) {
this.events.close();
}
if (obj.type === 'beat') {
this.events = new EventSource(`${this.API_ENDPOINT}/user/events?token=${this.user.eventToken}`);
this.events.onopen = event => {
console.info('Connection to event stream is open. Awaiting incoming events ...');
shownError = false;
};
this.events.onerror = error => {
if (shownError) return;
console.error('Connection to event stream has failed! Error: ' + error);
this.alert.error('Could not subscribe to events', 'Events');
shownError = true;
}
this.events.onmessage = async event => {
const jsonData = JSON.parse(event.data);
console.debug(`[SSE] ${event.type}: ${event.data}`);
switch (event.type) {
case 'beat':
if (this.beats !== undefined) {
this.beats.push(obj);
this.beatsEvent.next([obj]); // We just push one, so the map doesn't has to rebuild everything from scratch.
this.beats.push(jsonData);
this.beatsEvent.next([jsonData]); // We just push one, so the map doesn't has to rebuild everything from scratch.
this.beatStats.totalBeats++;
}
} else if (obj.type === 'phone_available') {
this.alert.dynamic(`Device ${obj.displayName} is now online`, obj.severity, 'Device');
} else if (obj.type === 'phone_register') {
console.debug('Received count:', jsonData);
break;
case 'message':
this.alert.info(event.data.message, 'SSE');
break;
case 'phone_available':
this.alert.dynamic(`Device ${jsonData.displayName} is now online`, jsonData.severity, 'Device');
break;
case 'phone_register':
await this.getPhones();
this.alert.dynamic(`New device "${obj.displayName}"`, obj.severity, 'New device');
} else if (obj.type === 'phone_alive') {
this.alert.dynamic('Device is now active', obj.severity, obj.displayName);
} else if (obj.type === 'phone_dead') {
this.alert.dynamic('Device is now offline', obj.severity, obj.displayName);
}
this.alert.dynamic(`New device "${jsonData.displayName}"`, jsonData.severity, 'New device');
break;
case 'phone_alive':
this.alert.dynamic('Device is now active', jsonData.severity, jsonData.displayName);
break;
case 'phone_dead':
this.alert.dynamic('Device is now offline', jsonData.severity, jsonData.displayName);
break;
}
});
};
}
/*
@@ -171,6 +199,7 @@ export class APIService {
*/
async login(username: string, password: string): Promise<ILogin> {
return new Promise<ILogin>(async (resolve, reject) => {
if (this.token !== undefined) reject('User is already logged in.');
this.httpClient.post(this.API_ENDPOINT + '/user/login', { username, password }, { responseType: 'json' })
.subscribe(async token => {
console.log(token);
@@ -181,9 +210,9 @@ export class APIService {
await this.getUserInfo();
await this.getNotifications();
this.mqttInit();
this.subscribeToEvents();
await this.getBeats();
await this.getBeats({ from: moment().startOf('day').unix(), to: moment().unix() });
await this.getBeatStats();
this.loginEvent.next(true);
this.alert.success('Login successful', 'Login', { duration: 2 });
@@ -385,7 +414,7 @@ export class APIService {
});
}
/* HELPER CLASSES */
/* HELPER FUNCTIONS */
degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180;
}
@@ -429,7 +458,14 @@ export class APIService {
return this.beats[this.beats.length - 1];
}
/**
* @returns `true` if user is logged in and `false` if not.
*/
hasSession(): boolean {
return this.token !== undefined;
}
getToken(): string {
return this.token;
}
}

View File

@@ -22,7 +22,6 @@ export class AppComponent implements OnInit{
async ngOnInit(): Promise<void> {
await this.api.login('admin', '$1KDaNCDlyXAOg');
this.alert.error('Audio test');
return;
}
}

View File

@@ -1,4 +1,4 @@
import { HttpClientModule } from '@angular/common/http';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
@@ -14,13 +14,13 @@ import { LoginComponent } from './login/login.component';
import { MapComponent } from './map/map.component';
import { UserComponent } from './user/user.component';
import { DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';
import { MqttModule } from 'ngx-mqtt';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { AdminComponent } from './admin/admin.component';
import { AlertComponent } from './_alert/alert/alert.component';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NotificationsComponent } from './notifications/notifications.component';
import { BackendInterceptor } from './interceptor';
@NgModule({
declarations: [
@@ -42,7 +42,6 @@ import { NotificationsComponent } from './notifications/notifications.component'
FontAwesomeModule,
FormsModule,
HttpClientModule,
MqttModule.forRoot({}),
NgxMapboxGLModule.withConfig({
accessToken: 'pk.eyJ1IjoibW9uZGVpMSIsImEiOiJja2dsY2ZtaG0xZ2o5MnR0ZWs0Mm82OTBpIn0.NzDWN3P6jJLmci_v3MM1tA'
}),
@@ -50,7 +49,13 @@ import { NotificationsComponent } from './notifications/notifications.component'
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
FontAwesomeModule
],
providers: [],
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: BackendInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpEvent, HttpResponse, HttpRequest, HttpHandler } from '@angular/common/http';
import { Observable } from 'rxjs';
import { APIService } from './api.service';
@Injectable()
export class BackendInterceptor implements HttpInterceptor {
constructor (private api: APIService) {}
intercept(httpRequest: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (this.api.hasSession()) {
console.debug('Inject token for', httpRequest.url);
return next.handle(httpRequest.clone({ setHeaders: { token: this.api.getToken() } }));
}
return next.handle(httpRequest);
}
}

View File

@@ -35,7 +35,7 @@ addEventListener('message', ({ data }) => {
const isNearPoint = isPointInRadius(
{ lat: beat2.coordinate[0], lng: beat2.coordinate[1] },
{ lat: beat.coordinate[0], lng: beat.coordinate[1] },
0.025
0.005
);
if (isNearPoint) {

View File

@@ -1,4 +1,4 @@
<mgl-map [style]="'mapbox://styles/mapbox/dark-v10'" [zoom]="[15]" [center]="[this.lastLocation[0], this.lastLocation[1]]" *ngIf="showMap">
<mgl-map [style]="'mapbox://styles/mapbox/outdoors-v11'" [zoom]="[15]" [center]="[this.lastLocation[0], this.lastLocation[1]]" *ngIf="showMap">
<mgl-geojson-source id="locHistory" [data]="data"></mgl-geojson-source>
<mgl-geojson-source id="locHistoryFiltered" [data]="mostVisitData"></mgl-geojson-source>
<mgl-geojson-source id="lastLoc" [data]="lastLocationData"></mgl-geojson-source>

165
logo2.svg Normal file
View File

@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="512"
height="512"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="logo2.svg">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient894">
<stop
style="stop-color:#ea0202;stop-opacity:0.21047072"
offset="0"
id="stop890" />
<stop
style="stop-color:#ea0202;stop-opacity:0"
offset="1"
id="stop892" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient882">
<stop
style="stop-color:#ea0202;stop-opacity:1;"
offset="0"
id="stop878" />
<stop
style="stop-color:#ea0202;stop-opacity:0;"
offset="1"
id="stop880" />
</linearGradient>
<linearGradient
inkscape:collect="always"
id="linearGradient866">
<stop
style="stop-color:#ea0202;stop-opacity:1;"
offset="0"
id="stop862" />
<stop
style="stop-color:#ea0202;stop-opacity:0;"
offset="1"
id="stop864" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient866"
id="linearGradient868"
x1="12.343376"
y1="22.496964"
x2="12.343376"
y2="-0.6001001"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient882"
id="linearGradient884"
x1="16"
y1="8"
x2="6.598474"
y2="17.448318"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient894"
id="linearGradient888"
gradientUnits="userSpaceOnUse"
x1="21.759228"
y1="2.2183397"
x2="0.69895685"
y2="21.501596"
gradientTransform="matrix(0.80230116,0,0,0.80230116,2.3723861,2.3723861)" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient894"
id="linearGradient898"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.52333779,0,0,0.52333779,-18.280053,5.7199465)"
x1="21.759228"
y1="2.2183397"
x2="0.69895685"
y2="21.501596" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#292929"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="198.97429"
inkscape:cy="322.59346"
inkscape:document-units="mm"
inkscape:current-layer="g860"
inkscape:document-rotation="0"
showgrid="false"
units="px"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="1920"
inkscape:window-y="0"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Ebene 1"
inkscape:groupmode="layer"
id="layer1">
<g
style="fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"
id="g860"
transform="scale(5.6666667)">
<path
stroke="none"
d="M 0,0 H 24 V 24 H 0 Z"
fill="none"
id="path843" />
<circle
cx="12"
cy="12"
r="9"
id="circle845"
style="stroke:url(#linearGradient868);stroke-opacity:1;stroke-width:0.88235294;stroke-miterlimit:4;stroke-dasharray:none" />
<path
d="M 12,17 11,13 7,12 16,8 Z"
id="path847"
style="stroke:url(#linearGradient884);stroke-opacity:1;stroke-width:0.88235294;stroke-miterlimit:4;stroke-dasharray:none;stroke-linecap:butt" />
<circle
cx="12"
cy="12"
r="7.2207103"
id="circle886"
style="stroke:url(#linearGradient888);stroke-width:0.707913;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<circle
cx="-12"
cy="12"
r="4.7100401"
id="circle896"
style="stroke:url(#linearGradient898);stroke-width:0.461769;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
transform="rotate(-90)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

256
package-lock.json generated
View File

@@ -2,15 +2,271 @@
"requires": true,
"lockfileVersion": 1,
"dependencies": {
"@types/bson": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.0.3.tgz",
"integrity": "sha512-mVRvYnTOZJz3ccpxhr3wgxVmSeiYinW+zlzQz3SXWaJmD1DuL05Jeq7nKw3SnbKmbleW5qrLG5vdyWe/A9sXhw==",
"requires": {
"@types/node": "*"
}
},
"@types/mongodb": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.3.tgz",
"integrity": "sha512-6YNqGP1hk5bjUFaim+QoFFuI61WjHiHE1BNeB41TA00Xd2K7zG4lcWyLLq/XtIp36uMavvS5hoAUJ+1u/GcX2Q==",
"requires": {
"@types/bson": "*",
"@types/node": "*"
}
},
"@types/node": {
"version": "14.14.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.16.tgz",
"integrity": "sha512-naXYePhweTi+BMv11TgioE2/FXU4fSl29HAH1ffxVciNsH3rYXjNP2yM8wqmSm7jS20gM8TIklKiTen+1iVncw=="
},
"angular-font-awesome": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/angular-font-awesome/-/angular-font-awesome-3.1.2.tgz",
"integrity": "sha1-k3hzJhLY6MceDXwvqg+t3H+Fjsk="
},
"bl": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz",
"integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==",
"requires": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
}
},
"bluebird": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
"integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA=="
},
"bson": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/bson/-/bson-1.1.5.tgz",
"integrity": "sha512-kDuEzldR21lHciPQAIulLs1LZlCXdLziXI6Mb/TDkwXhb//UORJNPXgcRs2CuO4H0DcMkpfT3/ySsP3unoZjBg=="
},
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"debug": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
"requires": {
"ms": "2.0.0"
},
"dependencies": {
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"denque": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz",
"integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ=="
},
"font-awesome": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
"kareem": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.2.tgz",
"integrity": "sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ=="
},
"memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"optional": true
},
"mongodb": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.6.3.tgz",
"integrity": "sha512-rOZuR0QkodZiM+UbQE5kDsJykBqWi0CL4Ec2i1nrGrUI3KO11r6Fbxskqmq3JK2NH7aW4dcccBuUujAP0ERl5w==",
"requires": {
"bl": "^2.2.1",
"bson": "^1.1.4",
"denque": "^1.4.1",
"require_optional": "^1.0.1",
"safe-buffer": "^5.1.2",
"saslprep": "^1.0.0"
}
},
"mongoose": {
"version": "5.11.8",
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.11.8.tgz",
"integrity": "sha512-RRfrYLg7pyuyx7xu5hwadjIZZJB9W2jqIMkL1CkTmk/uOCX3MX2tl4BVIi2rJUtgMNwn6dy3wBD3soB8I9Nlog==",
"requires": {
"@types/mongodb": "^3.5.27",
"bson": "^1.1.4",
"kareem": "2.3.2",
"mongodb": "3.6.3",
"mongoose-legacy-pluralize": "1.0.2",
"mpath": "0.8.1",
"mquery": "3.2.3",
"ms": "2.1.2",
"regexp-clone": "1.0.0",
"safe-buffer": "5.2.1",
"sift": "7.0.1",
"sliced": "1.0.1"
}
},
"mongoose-legacy-pluralize": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz",
"integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ=="
},
"mpath": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.1.tgz",
"integrity": "sha512-norEinle9aFc05McBawVPwqgFZ7npkts9yu17ztIVLwPwO9rq0OTp89kGVTqvv5rNLMz96E5iWHpVORjI411vA=="
},
"mquery": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.3.tgz",
"integrity": "sha512-cIfbP4TyMYX+SkaQ2MntD+F2XbqaBHUYWk3j+kqdDztPWok3tgyssOZxMHMtzbV1w9DaSlvEea0Iocuro41A4g==",
"requires": {
"bluebird": "3.5.1",
"debug": "3.1.0",
"regexp-clone": "^1.0.0",
"safe-buffer": "5.1.2",
"sliced": "1.0.1"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"regexp-clone": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz",
"integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw=="
},
"require_optional": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz",
"integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==",
"requires": {
"resolve-from": "^2.0.0",
"semver": "^5.1.0"
}
},
"resolve-from": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
"integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"saslprep": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
"integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
"optional": true,
"requires": {
"sparse-bitfield": "^3.0.3"
}
},
"semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
},
"sift": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/sift/-/sift-7.0.1.tgz",
"integrity": "sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g=="
},
"sliced": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
"integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
},
"sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
"optional": true,
"requires": {
"memory-pager": "^1.0.2"
}
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
},
"dependencies": {
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}
}
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
}
}
}