3 Commits

Author SHA1 Message Date
19b7c05d75 Socket connection now works
- Pairing a new device works

(I did a lot since the last commit)
2021-11-14 01:42:21 +01:00
28e85ea730 Fix typo in package.json 2021-05-06 15:25:28 +02:00
9f1aa50681 Add barebone to support socket.io
(This is more a temp commit to update the frontend)
2021-05-06 15:24:20 +02:00
80 changed files with 8771 additions and 24930 deletions

1
.gitignore vendored
View File

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

122
android/.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,122 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.8" /> <bytecodeTargetLevel target="11" />
</component> </component>
</project> </project>

View File

@@ -4,7 +4,7 @@
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="PLATFORM" /> <option name="testRunner" value="GRADLE" />
<option name="disableWrapperSourceDistributionNotification" value="true" /> <option name="disableWrapperSourceDistributionNotification" value="true" />
<option name="distributionType" value="DEFAULT_WRAPPED" /> <option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
@@ -16,7 +16,6 @@
</set> </set>
</option> </option>
<option name="resolveModulePerSourceSet" value="false" /> <option name="resolveModulePerSourceSet" value="false" />
<option name="useQualifiedModuleNames" value="true" />
</GradleProjectSettings> </GradleProjectSettings>
</option> </option>
</component> </component>

13
android/.idea/misc.xml generated
View File

@@ -1,6 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK"> <component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/layout/activity_main.xml" value="0.22291666666666668" />
<entry key="app/src/main/res/layout/activity_setup.xml" value="0.3578125" />
<entry key="app/src/main/res/layout/content_setup.xml" value="0.3578125" />
<entry key="app/src/main/res/layout/input_dialog.xml" value="0.19791666666666666" />
<entry key="app/src/main/res/layout/setup.xml" value="0.33" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -21,6 +21,7 @@ android {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
debuggable false
} }
} }
compileOptions { compileOptions {
@@ -30,10 +31,17 @@ android {
kotlinOptions { kotlinOptions {
jvmTarget = '1.8' jvmTarget = '1.8'
} }
buildFeatures {
viewBinding true
}
} }
dependencies { dependencies {
implementation ('io.socket:socket.io-client:2.0.1') {
// excluding org.json which is provided by Android
exclude group: 'org.json', module: 'json'
}
implementation fileTree(dir: 'libs', include: ['lineage-sdk.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.appcompat:appcompat:1.1.0'
@@ -44,9 +52,8 @@ dependencies {
implementation 'com.squareup.moshi:moshi:1.11.0' implementation 'com.squareup.moshi:moshi:1.11.0'
implementation 'com.squareup.moshi:moshi-kotlin:1.11.0' implementation 'com.squareup.moshi:moshi-kotlin:1.11.0'
implementation 'com.rabbitmq:amqp-client:5.9.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.+' testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
} }

Binary file not shown.

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
package="de.nicolasklier.livebeat"> package="de.nicolasklier.livebeat" >
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@@ -19,18 +19,26 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true" android:theme="@style/Theme.Livebeat"
android:theme="@style/Theme.Livebeat"> android:usesCleartextTraffic="true" >
<activity
android:name=".SetupActivity"
android:exported="false"
android:label="@string/title_activity_setup"
android:theme="@style/Theme.Livebeat.NoActionBar" />
<service android:name=".TrackerService" /> <service android:name=".TrackerService" />
<receiver android:name=".BootReceiver">
<intent-filter > <receiver android:name=".BootReceiver" >
<action android:name="android.intent.action.BOOT_COMPLETED"/> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.Livebeat.NoActionBar"> android:theme="@style/Theme.Livebeat.NoActionBar" >
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -1,90 +0,0 @@
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

@@ -1,58 +0,0 @@
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

@@ -10,17 +10,13 @@ import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.telephony.TelephonyManager
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -28,12 +24,9 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.net.ConnectException
import java.util.logging.Logger
@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS")
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -48,16 +41,25 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
fun checkIfPhoneIsRegistered() { fun checkIfPhoneIsRegistered() {
val pref = this.getPreferences(Context.MODE_PRIVATE) ?: return
val accessToken = pref.getString("accessToken", "");
// App is not setup
if (accessToken == "") {
val intent = Intent(baseContext, SetupActivity::class.java);
startActivity(intent);
}
if (TOKEN == "") return; if (TOKEN == "") return;
Thread(Runnable { Thread(Runnable {
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
/*val client = OkHttpClient() val client = OkHttpClient()
val req = Request.Builder() val req = Request.Builder()
.url("$API_URL/phone/$androidId") .url("$API_URL/phone/$androidId")
.header("token", TOKEN) .header("token", TOKEN)
.get() .get()
.build()*/ .build()
val response = HttpRequests.get("$API_URL/phone/$androidId") val response = client.newCall(req).execute()
if (response.code != 200) { if (response.code != 200) {
Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Device isn't registered yet. Registering ...", Snackbar.LENGTH_SHORT) Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Device isn't registered yet. Registering ...", Snackbar.LENGTH_SHORT)
@@ -77,7 +79,7 @@ class MainActivity : AppCompatActivity() {
val phoneToJson = moshi.adapter(Phone::class.java) val phoneToJson = moshi.adapter(Phone::class.java)
val json = phoneToJson.toJson(phone) val json = phoneToJson.toJson(phone)
/*val createPhone = Request.Builder() val createPhone = Request.Builder()
.url("$API_URL/phone") .url("$API_URL/phone")
.post( .post(
(json).toRequestBody() (json).toRequestBody()
@@ -85,8 +87,7 @@ class MainActivity : AppCompatActivity() {
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("token", TOKEN) .header("token", TOKEN)
.build() .build()
client.newCall(createPhone).execute()*/ client.newCall(createPhone).execute()
HttpRequests.post("$API_URL/phone", json);
} }
}).start() }).start()
} }
@@ -98,8 +99,6 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
setSupportActionBar(findViewById(R.id.toolbar)) setSupportActionBar(findViewById(R.id.toolbar))
//val process = Runtime.getRuntime().exec("su")
// Check authorization // Check authorization
val backendChecks = Thread(Runnable { val backendChecks = Thread(Runnable {
val username = findViewById<TextView>(R.id.username).text val username = findViewById<TextView>(R.id.username).text
@@ -107,29 +106,8 @@ class MainActivity : AppCompatActivity() {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val jsonToLogin = moshi.adapter(Login::class.java) 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 client = OkHttpClient()
val req = Request.Builder() val req = Request.Builder()
.url("$API_URL/user/login")
.post(
(token).toRequestBody()
)
.header("Content-Type", "application/json")
.build()
// Check if server is available.
try {
val testReq = Request.Builder()
.url("$API_URL/user/login") .url("$API_URL/user/login")
.post( .post(
("{ \"username\": \"" + username + "\"," + ("{ \"username\": \"" + username + "\"," +
@@ -137,10 +115,6 @@ class MainActivity : AppCompatActivity() {
) )
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.build() .build()
client.newCall(testReq).execute();
} catch (e: ConnectException) {
}
val loginResponse = client.newCall(req).execute() val loginResponse = client.newCall(req).execute()
val responseBody = loginResponse.body!!.string() val responseBody = loginResponse.body!!.string()
@@ -169,10 +143,8 @@ class MainActivity : AppCompatActivity() {
val userInfoResponseBody = userinfoResponse.body!!.string() val userInfoResponseBody = userinfoResponse.body!!.string()
USER = jsonToUser.fromJson(userInfoResponseBody) USER = jsonToUser.fromJson(userInfoResponseBody)
val intent = Intent(this, TrackerService::class.java)
// Only start service if authentication went good. // Only start service if authentication went good.
startService(intent) // startService(Intent(this, TrackerService::class.java))
Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Login succeeded", Snackbar.LENGTH_SHORT) Snackbar.make(findViewById<FloatingActionButton>(R.id.fab), "Login succeeded", Snackbar.LENGTH_SHORT)
.setBackgroundTint(Color.GREEN) .setBackgroundTint(Color.GREEN)
@@ -201,6 +173,7 @@ class MainActivity : AppCompatActivity() {
this.broadcastReceiver = object : BroadcastReceiver() { this.broadcastReceiver = object : BroadcastReceiver() {
@SuppressLint("CutPasteId") @SuppressLint("CutPasteId")
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val statusRabbit = intent.getBooleanExtra("statusRabbit", false)
val statusHttp = intent.getIntExtra("statusHttp", 404) val statusHttp = intent.getIntExtra("statusHttp", 404)
/*if (statusHttp == 200) { /*if (statusHttp == 200) {
@@ -224,6 +197,14 @@ class MainActivity : AppCompatActivity() {
} }
} }
@ColorInt
private fun getAccentColor(): Int {
val attr = intArrayOf(android.R.attr.colorAccent)
val typedArray = obtainStyledAttributes(android.R.style.Theme_DeviceDefault, attr)
return typedArray.getColor(0, Color.BLACK)
.also { typedArray.recycle() }
}
private fun checkPerms() { private fun checkPerms() {
if (ActivityCompat.checkSelfPermission( if (ActivityCompat.checkSelfPermission(
this, this,

View File

@@ -22,12 +22,21 @@ class Phone(
val architecture: String val architecture: String
) {} ) {}
class PhoneRegistration (
val phone: Phone,
val token: String
) {}
class PhoneSubmitPairCode (
val phoneId: String,
val code: String
) {}
class User( class User(
val name: String, val name: String,
val type: String, val type: String,
val lastLogin: String, val lastLogin: String,
val twoFASecret: String?, val twoFASecret: String?,
val eventToken: String,
val createdAt: String val createdAt: String
) )

View File

@@ -0,0 +1,195 @@
package de.nicolasklier.livebeat
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.view.View
import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.ui.AppBarConfiguration
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import de.nicolasklier.livebeat.databinding.ActivitySetupBinding
import de.nicolasklier.livebeat.dialogs.ErrorDialog
import de.nicolasklier.livebeat.dialogs.InputDialog
import io.socket.client.IO
import io.socket.client.Socket
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.lang.Exception
class SetupActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var binding: ActivitySetupBinding
private var host = ""
private var username = ""
private var password = ""
private var token = ""
private lateinit var socket: Socket;
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySetupBinding.inflate(layoutInflater)
setContentView(binding.root)
findViewById<Button>(R.id.btnLogin).setOnClickListener {
kotlin.run {
host = findViewById<EditText>(R.id.inputServerIp).text.toString()
username = findViewById<EditText>(R.id.inputUsername).text.toString()
password = findViewById<EditText>(R.id.inputPassword).text.toString()
findViewById<LinearLayout>(R.id.connectLayout).visibility = View.VISIBLE;
tryConnect()
}
}
}
private fun updateStatus(text: String) {
runOnUiThread {
findViewById<TextView>(R.id.connectStatus).text = text;
}
}
private fun throwError(text: String) {
val dialog = ErrorDialog(text)
dialog.show(supportFragmentManager, "");
runOnUiThread {
findViewById<LinearLayout>(R.id.connectLayout).visibility = View.GONE;
}
}
private fun tryConnect() {
updateStatus("Connect to backend");
// Connection values
val options = IO.Options.builder()
.setTransports(arrayOf("websocket"))
.build();
try {
socket = IO.socket("http://$host:8040", options)
socket.connect()
} catch (e: Exception) {
updateStatus("Failed to connect");
Log.e("Socket.io", "Unable to connect to socket: ${e.message}")
return;
}
socket.on("connect") { args ->
run {
updateStatus("Connected")
login();
}
}
}
private fun login() {
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val jsonToLogin = moshi.adapter(Login::class.java)
val client = OkHttpClient()
val req = Request.Builder()
.url("${MainActivity.API_URL}/user/login")
.post(
("{ \"username\": \"" + username + "\"," +
"\"password\": \"" + password + "\" }").toRequestBody()
)
.header("Content-Type", "application/json")
.build()
val loginResponse = client.newCall(req).execute()
val responseBody = loginResponse.body!!.string()
if (loginResponse.code == 200) {
token = jsonToLogin.fromJson(responseBody)!!.token
requestAccess()
} else {
throwError("Username or password is wrong.")
}
}
private fun requestAccess() {
var phoneId = "";
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
val phone = Phone(
androidId,
Build.MODEL,
Build.PRODUCT,
Build.VERSION.BASE_OS + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME,
System.getProperty("os.arch")
)
val phoneRegistration = PhoneRegistration(
phone,
token
)
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val phoneToJson = moshi.adapter(PhoneRegistration::class.java)
val json = phoneToJson.toJson(phoneRegistration)
socket.emit("requestAccess", json)
updateStatus("Await new phone id")
// We received a response from the backend containing the phone id.
socket.on("requestAccess") { args ->
run {
phoneId = (args[0] as JSONObject).getString("phoneId");
updateStatus("Await user to enter pair code")
fun promptUser() {
val dialog = InputDialog("Pair code" , "Look into your device overview to get the pair code.") { choice, code ->
if (choice == InputDialog.UserChoice.CANCEL) {
updateStatus("Process has been canceled by user.")
return@InputDialog;
}
updateStatus("Validate code $code for $phoneId")
val submitCode = PhoneSubmitPairCode(
phoneId,
code
);
val submitCodeToJson = moshi.adapter(PhoneSubmitPairCode::class.java)
val submitCodeJson = submitCodeToJson.toJson(submitCode)
socket.emit("submitPairCode", submitCodeJson);
}
dialog.show(supportFragmentManager, "");
}
// The backend only calls this event again if the code is incorrect. Otherwise `accessGranted` is called.
socket.on("submitPairCode") { args ->
run {
if ((args[0] as String) == "") {
updateStatus("Code has been incorrect")
// Prompt user to enter code as long as it's invalid
promptUser()
} else {
// If response is not empty, we received a token (let's just hope that)
updateStatus("Code has been correct")
val sharedPref = this.getPreferences(Context.MODE_PRIVATE) ?: return@on
with (sharedPref.edit()) {
putString("accessToken", args[0] as String)
commit()
}
// Return to previous activity.
this.finish()
}
}
}
promptUser()
}
}
}
}

View File

@@ -10,19 +10,33 @@ import android.content.pm.PackageManager
import android.graphics.Color import android.graphics.Color
import android.location.LocationManager import android.location.LocationManager
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.VISIBILITY_PRIVATE
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import com.rabbitmq.client.Channel import com.rabbitmq.client.Channel
import com.rabbitmq.client.Connection import com.rabbitmq.client.Connection
import com.rabbitmq.client.ConnectionFactory
import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import io.socket.client.IO
import io.socket.client.Socket
import io.socket.emitter.Emitter
import okhttp3.EventListener
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import java.lang.Exception
import java.util.concurrent.TimeoutException
class TrackerService : Service() { class TrackerService : Service() {
var isSocketConnected = false;
val conn = arrayOfNulls<Connection>(1) val conn = arrayOfNulls<Connection>(1)
val channel = arrayOfNulls<Channel>(1) val channel = arrayOfNulls<Channel>(1)
@@ -30,9 +44,50 @@ class TrackerService : Service() {
return null return null
} }
@SuppressLint("CheckResult") private fun connectSocket() {
private fun subscribeToEvents() { // This thread only connects to RabbitMQ
HttpRequests.sse("http://192.168.178.26/user/events?token=${MainActivity.USER?.eventToken}") val connectionThread = Thread(Runnable {
val socket: Socket;
// Connection values
val options = IO.Options.builder()
.setTransports(arrayOf("websocket"))
.build();
try {
socket = IO.socket("http://192.168.178.26:8040", options)
socket.connect()
} catch (e: Exception) {
Log.e("Socket.io", "Unable to connect to socket: ${e.message}")
return@Runnable
}
socket.on("test") { args ->
run {
Log.i("Socket.io", args[0].toString());
}
}
socket.on("connect") { args ->
run {
isSocketConnected = true;
}
}
socket.on("disconnect") { args ->
run {
isSocketConnected = false;
}
}
if (socket.connected()) {
socket.emit("test", "This is a message from my phone!")
Log.i("Socket.io", "Published test message")
} else {
Log.e("Socket.io", "Cannot send test message because I'm not connected with the Socket.io server.")
}
})
connectionThread.start()
} }
@SuppressLint("HardwareIds") @SuppressLint("HardwareIds")
@@ -70,13 +125,15 @@ class TrackerService : Service() {
} }
} }
subscribeToEvents() connectSocket()
startForeground() start()
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
private fun startForeground() { private fun start() {
val noticicationIntent = Intent(this, MainActivity::class.java) val noticicationIntent = Intent(this, MainActivity::class.java).apply {
action = "Stop"
}
val pendingIntent = PendingIntent.getActivity(this, 0, noticicationIntent, 0) val pendingIntent = PendingIntent.getActivity(this, 0, noticicationIntent, 0)
val chan = NotificationChannel( val chan = NotificationChannel(
NOTIF_CHANNEL_ID, NOTIF_CHANNEL_ID,
@@ -86,17 +143,24 @@ class TrackerService : Service() {
chan.lockscreenVisibility = Notification.VISIBILITY_SECRET chan.lockscreenVisibility = Notification.VISIBILITY_SECRET
val manager = (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager) val manager = (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
manager.createNotificationChannel(chan) manager.createNotificationChannel(chan)
startForeground(NOTIF_ID, NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
val notification = NotificationCompat.Builder(this, NOTIF_CHANNEL_ID)
.setOngoing(true) .setOngoing(true)
.setContentTitle("Livebeat") .setContentTitle("Livebeat")
.setContentText("Tracker is running") .setContentText("Tracker is running")
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE) .setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationManager.IMPORTANCE_LOW) .setPriority(NotificationManager.IMPORTANCE_LOW)
.setSmallIcon(R.mipmap.ic_launcher)
.setChannelId(NOTIF_CHANNEL_ID) .setChannelId(NOTIF_CHANNEL_ID)
.setColorized(true) .setColorized(true)
.setShowWhen(false)
.setVisibility(VISIBILITY_SECRET)
.setColor(Color.BLACK) .setColor(Color.BLACK)
.build()) .addAction(R.drawable.ic_launcher_background, "Stop", pendingIntent)
.build()
manager.notify(0, notification)
} }
companion object { companion object {

View File

@@ -0,0 +1,23 @@
package de.nicolasklier.livebeat.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
class ErrorDialog(val message: String) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val builder = AlertDialog.Builder(it)
builder.setMessage(message)
.setPositiveButton("Ok"
) { dialog, _ ->
dialog.cancel()
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View File

@@ -0,0 +1,43 @@
package de.nicolasklier.livebeat.dialogs
import android.app.AlertDialog
import android.app.Dialog
import android.os.Bundle
import android.widget.EditText
import androidx.fragment.app.DialogFragment
import de.nicolasklier.livebeat.R
import de.nicolasklier.livebeat.User
class InputDialog(val title: String, val message: String, val callback: (UserChoice, String) -> Unit) : DialogFragment() {
enum class UserChoice {
CANCEL,
SUBMIT
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val inflater = requireActivity().layoutInflater;
val view = inflater.inflate(R.layout.input_dialog, null);
val builder = AlertDialog.Builder(it)
builder
.setMessage(message)
.setTitle(title)
.setCancelable(false)
.setView(view)
.setNegativeButton("Cancel"
) { dialog, _ ->
callback(UserChoice.CANCEL, "")
dialog.cancel()
}
.setPositiveButton("Submit"
) { dialog, _ ->
callback(UserChoice.SUBMIT, view.findViewById<EditText>(R.id.input).text.toString());
}
builder.create()
} ?: throw IllegalStateException("Activity cannot be null")
}
}

View File

@@ -64,12 +64,6 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"/>
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="120dp" /> android:layout_height="120dp" />

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".SetupActivity">
<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="176dp"
android:layout_marginTop="51dp"
android:layout_marginEnd="177dp"
android:text="Connect"
android:textColor="@color/white"
android:textSize="34sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/linearLayout2"
android:layout_width="331dp"
android:layout_height="wrap_content"
android:layout_marginTop="200dp"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/inputServerIp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="Server IP"
android:inputType="textPersonName"
android:text="192.168.178.26" />
<EditText
android:id="@+id/inputUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="Username"
android:inputType="textPersonName"
android:text="admin"
android:textColor="@color/white" />
<EditText
android:id="@+id/inputPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="Password"
android:inputType="textPassword"
android:text="$1KDaNCDlyXAOg"
android:textColor="@color/white" />
</LinearLayout>
<TextView
android:id="@+id/textView2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="174dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="179dp"
android:text="You'll need to login to a Livebeat server instance."
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.489"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
<Button
android:id="@+id/btnLogin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="161dp"
android:layout_marginTop="85dp"
android:layout_marginEnd="162dp"
android:text="Login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/linearLayout2" />
<LinearLayout
android:id="@+id/connectLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnLogin">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="181dp"
android:layout_marginTop="39dp"
android:layout_marginEnd="182dp"
android:progressTint="@color/white"
android:visibility="visible" />
<TextView
android:id="@+id/connectStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textAlignment="center" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<EditText
android:id="@+id/input"
android:inputType="number"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:layout_marginBottom="16dp"
android:fontFamily="sans-serif"/>
</LinearLayout>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/FirstFragment">
<fragment
android:id="@+id/FirstFragment"
android:name="de.nicolasklier.livebeat.FirstFragment"
android:label="@string/first_fragment_label"
tools:layout="@layout/fragment_first" >
<action
android:id="@+id/action_FirstFragment_to_SecondFragment"
app:destination="@id/SecondFragment" />
</fragment>
<fragment
android:id="@+id/SecondFragment"
android:name="de.nicolasklier.livebeat.SecondFragment"
android:label="@string/second_fragment_label"
tools:layout="@layout/fragment_second" >
<action
android:id="@+id/action_SecondFragment_to_FirstFragment"
app:destination="@id/FirstFragment" />
</fragment>
</navigation>

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">200dp</dimen>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<dimen name="fab_margin">48dp</dimen>
</resources>

View File

@@ -9,4 +9,5 @@
<string name="hello_first_fragment">Hello first fragment</string> <string name="hello_first_fragment">Hello first fragment</string>
<string name="hello_second_fragment">Hello second fragment. Arg: %1$s</string> <string name="hello_second_fragment">Hello second fragment. Arg: %1$s</string>
<string name="title_activity_setup">Setup</string>
</resources> </resources>

View File

@@ -1,6 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. --> <!-- Base application theme. -->
<style name="Theme.Livebeat" parent="Theme.AppCompat.Light"> <style name="Theme.Livebeat" parent="Theme.MaterialComponents.DayNight">
<!-- Primary brand color. --> <!-- Primary brand color. -->
<item name="colorPrimary">@color/black</item> <item name="colorPrimary">@color/black</item>
<item name="colorPrimaryVariant">@color/black2</item> <item name="colorPrimaryVariant">@color/black2</item>

View File

@@ -7,7 +7,7 @@ buildscript {
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:4.1.0" classpath "com.android.tools.build:gradle:4.1.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20"
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // in the individual module build.gradle files

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

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

View File

@@ -1,8 +0,0 @@
<?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

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

View File

@@ -1,4 +0,0 @@
<?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>

View File

@@ -1,8 +0,0 @@
<?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
View File

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

View File

@@ -7,14 +7,15 @@ import * as figlet from 'figlet';
import * as mongoose from 'mongoose'; import * as mongoose from 'mongoose';
import { exit } from 'process'; import { exit } from 'process';
import * as winston from 'winston'; import * as winston from 'winston';
import { createServer } from 'http';
import { config } from './config'; import { config } from './config';
import { GetBeat, GetBeatStats } from './endpoints/beat'; import { GetBeat, GetBeatStats } from './endpoints/beat';
import { getNotification } from './endpoints/notification'; import { getNotification } from './endpoints/notification';
import { GetPhone, PostPhone } from './endpoints/phone'; import { GetPhone, PostPhone } from './endpoints/phone';
import { DeleteUser, GetUser, LoginUser, MW_User, PatchUser, PostUser, UserEvents } from './endpoints/user'; import { DeleteUser, GetUser, LoginUser, MW_User, PatchUser, PostUser } from './endpoints/user';
import { hashPassword, randomPepper, randomString } from './lib/crypto'; import { hashPassword, randomPepper, randomString } from './lib/crypto';
import { EventManager } from './lib/eventManager'; import { SocketManager } from './lib/socketio';
import { RabbitMQ } from './lib/rabbit';
import { UserType } from './models/user/user.interface'; import { UserType } from './models/user/user.interface';
import { User } from './models/user/user.model'; import { User } from './models/user/user.model';
@@ -27,8 +28,6 @@ export const JWT_SECRET = process.env.JWT_SECRET || "";
export const IS_DEBUG = process.env.DEBUG == 'true'; export const IS_DEBUG = process.env.DEBUG == 'true';
export let logger: winston.Logger; export let logger: winston.Logger;
export let rabbitmq: RabbitMQ;
export let eventManager: EventManager = new EventManager();
async function run() { async function run() {
const { combine, timestamp, label, printf, prettyPrint } = winston.format; const { combine, timestamp, label, printf, prettyPrint } = winston.format;
@@ -109,10 +108,8 @@ async function run() {
await User.create({ await User.create({
name: 'admin', name: 'admin',
password: await hashPassword(randomPassword + salt + randomPepper()), password: await hashPassword(randomPassword + salt + randomPepper()),
eventToken: randomString(16),
salt, salt,
createdAt: Date.now(), lastLogin: new Date(0),
lastLogin: 0,
type: UserType.ADMIN type: UserType.ADMIN
}); });
logger.info("==================================================="); logger.info("===================================================");
@@ -125,14 +122,23 @@ async function run() {
/** /**
* HTTP server * HTTP server
*/ */
logger.debug("Preparing HTTP server ...")
const app = express(); const app = express();
app.use(express.json()); const server = createServer(app);
app.use(cors()); app.use(cors());
app.options('*', cors());
app.use(express.json());
app.use(bodyParser.json({ limit: '5kb' })); app.use(bodyParser.json({ limit: '5kb' }));
app.use((req, res, next) => { app.use((req, res, next) => {
res.on('finish', () => { res.on('finish', () => {
const done = Date.now(); // Censor any user passwords
if (req.body.password != null) {
req.body.password = "***********";
}
logger.debug(`${req.method} - ${req.url} ${JSON.stringify(req.body)} -> ${res.statusCode}`); logger.debug(`${req.method} - ${req.url} ${JSON.stringify(req.body)} -> ${res.statusCode}`);
}); });
next(); next();
@@ -140,12 +146,11 @@ async function run() {
app.get('/', (req, res) => res.status(200).send('OK')); app.get('/', (req, res) => res.status(200).send('OK'));
// User authentication & actions // User authentication
app.post('/user/login', (req, res) => LoginUser(req, res)); app.post('/user/login', (req, res) => LoginUser(req, res));
// CRUD user // CRUD user
app.get('/user/notification', MW_User, (req, res) => getNotification(req, res)); // Notifications 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.get('/user/', MW_User, (req, res) => GetUser(req, res));
app.post('/user/', MW_User, (req, res) => PostUser(req, res)); app.post('/user/', MW_User, (req, res) => PostUser(req, res));
app.get('/user/:id', MW_User, (req, res) => GetUser(req, res)); app.get('/user/:id', MW_User, (req, res) => GetUser(req, res));
@@ -161,16 +166,11 @@ async function run() {
app.get('/beat/', MW_User, (req, res) => GetBeat(req, res)); app.get('/beat/', MW_User, (req, res) => GetBeat(req, res));
app.get('/beat/stats', MW_User, (req, res) => GetBeatStats(req, res)); app.get('/beat/stats', MW_User, (req, res) => GetBeatStats(req, res));
app.listen(config.http.port, config.http.host, () => { const socketManager = new SocketManager(server);
server.listen(config.http.port, config.http.host, () => {
logger.info(`HTTP server is running at ${config.http.host}:${config.http.port}`); logger.info(`HTTP server is running at ${config.http.host}:${config.http.port}`);
}); });
/**
* Message broker
*/
rabbitmq = new RabbitMQ();
//await rabbitmq.init();
//logger.info("Connected with message broker.");
} }
run(); run();

View File

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

View File

@@ -1,20 +1,20 @@
import { Response } from "express"; import { Response } from "express";
import { eventManager, logger } from "../app";
import { LivebeatRequest } from "../lib/request"; import { LivebeatRequest } from "../lib/request";
import { IBeat } from "../models/beat/beat.interface"; import { IBeat } from "../models/beat/beat.interface";
import { Beat } from "../models/beat/beat.model."; import { Beat } from "../models/beat/beat.model.";
import { ISeverity } from "../models/notifications/notification.interface";
import { Phone } from "../models/phone/phone.model"; 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) { export async function GetBeatStats(req: LivebeatRequest, res: Response) {
const phones = await Phone.find({ user: req.user?._id }); const phones = await Phone.find({ user: req.user?._id }).exec();
const perPhone: any = {}; const perPhone: any = {};
let totalBeats = 0; let totalBeats = 0;
if (phones[0] == undefined) return;
const phone = phones[0];
for (let i = 0; i < phones.length; i++) { for (let i = 0; i < phones.length; i++) {
const beatCount = await Beat.countDocuments({ phone: phones[i] }); const beatCount = await Beat.countDocuments({ [phone.id]: phone.id });
perPhone[phones[i]._id] = {}; perPhone[phones[i]._id] = {};
perPhone[phones[i]._id] = beatCount; perPhone[phones[i]._id] = beatCount;
totalBeats += beatCount; totalBeats += beatCount;
@@ -24,26 +24,23 @@ export async function GetBeatStats(req: LivebeatRequest, res: Response) {
} }
export async function GetBeat(req: LivebeatRequest, res: Response) { export async function GetBeat(req: LivebeatRequest, res: Response) {
const from: number = Number(req.query.from || 0); const from: number = Number(req.query.from);
const to: number = Number(req.query.to || Date.now() / 1000); const to: number = Number(req.query.to);
const limit: number = Number(req.query.limit || 10000); const limit: number = Number(req.query.limit || 10000);
const sort: number = Number(req.query.sort || 1); // Either -1 or 1 const sort: number = Number(req.query.sort || 1); // Either -1 or 1
const phoneId = req.query.phoneId; const phoneId = req.query.phoneId;
// Grab default phone if non was provided. // 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 }); 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) { if (phone !== null) {
beats = await Beat.find( beats = await Beat.find(
{ {
phone: phone._id, phone: phone._id,
createdAt: { createdAt: {
$gte: new Date((from)), $gte: new Date((from | 0) * 1000),
$lte: new Date(to * 1000) $lte: new Date((to | Date.now() /1000) * 1000)
} }
}).sort({ _id: sort }).limit(limit); }).sort({ _id: sort }).limit(limit);
res.status(200).send(beats); res.status(200).send(beats);
@@ -51,61 +48,3 @@ export async function GetBeat(req: LivebeatRequest, res: Response) {
res.status(404).send({ message: 'Phone not found' }); 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

@@ -1,10 +1,11 @@
import { Response } from "express"; import { Response } from "express";
import { logger, rabbitmq } from "../app"; import { logger } from "../app";
import { LivebeatRequest } from "../lib/request"; import { LivebeatRequest } from "../lib/request";
import { Beat } from "../models/beat/beat.model."; import { Beat } from "../models/beat/beat.model.";
import { Phone } from "../models/phone/phone.model"; import { Phone } from "../models/phone/phone.model";
export async function GetPhone(req: LivebeatRequest, res: Response) { export async function GetPhone(req: LivebeatRequest, res: Response) {
const phoneId: String = req.params['id']; const phoneId: String = req.params['id'];
@@ -65,7 +66,7 @@ export async function PostPhone(req: LivebeatRequest, res: Response) {
}); });
logger.info(`New device (${displayName}) registered for ${req.user?.name}.`); logger.info(`New device (${displayName}) registered for ${req.user?.name}.`);
rabbitmq.publish(req.user?.id, newPhone.toJSON(), 'phone_register') //rabbitmq.publish(req.user?.id, newPhone.toJSON(), 'phone_register')
res.status(200).send(); res.status(200).send();
} }

View File

@@ -1,7 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { decode, sign, verify } from 'jsonwebtoken'; import { decode, sign, verify } from 'jsonwebtoken';
import { eventManager, JWT_SECRET, logger, RABBITMQ_URI } from '../app'; import { JWT_SECRET, logger, RABBITMQ_URI } from '../app';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
import { config } from '../config'; import { config } from '../config';
import { hashPassword, randomPepper, randomString, verifyPassword } from '../lib/crypto'; 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 salt = randomString(config.authentification.salt_length);
const eventToken = randomString(16); const brokerToken = randomString(16);
const hashedPassword = await hashPassword(password + salt + randomPepper()).catch(error => { const hashedPassword = await hashPassword(password + salt + randomPepper()).catch(error => {
res.status(400).send({ message: 'Provided password is too weak and cannot be used.' }); res.status(400).send({ message: 'Provided password is too weak and cannot be used.' });
return; return;
@@ -61,7 +61,7 @@ export async function PostUser(req: LivebeatRequest, res: Response) {
name, name,
password: hashedPassword, password: hashedPassword,
salt, salt,
eventToken, brokerToken,
type, type,
lastLogin: new Date(0) lastLogin: new Date(0)
}); });
@@ -72,27 +72,6 @@ export async function PostUser(req: LivebeatRequest, res: Response) {
res.status(200).send({ setupToken }); 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) { export async function DeleteUser(req: Request, res: Response) {
} }
@@ -132,7 +111,7 @@ export async function LoginUser(req: Request, res: Response) {
} }
// We're good. Create JWT token. // We're good. Create JWT token.
const token = sign({ user: user._id }, JWT_SECRET, { expiresIn: '30d' }); const token = sign({ user: user._id, type: 'frontend' }, JWT_SECRET, { expiresIn: '30d' });
user.lastLogin = new Date(Date.now()); user.lastLogin = new Date(Date.now());
await user.save(); await user.save();

View File

@@ -1,6 +1,7 @@
import { hash, verify } from 'argon2'; import { hash, verify } from 'argon2';
import { verify as jwtVerify } from 'jsonwebtoken';
import { config } from '../config'; import { config } from '../config';
import { IS_DEBUG, logger } from '../app'; import { IS_DEBUG, JWT_SECRET, logger } from '../app';
export async function hashPassword(input: string): Promise<string> { export async function hashPassword(input: string): Promise<string> {
const start = Date.now(); const start = Date.now();
@@ -64,9 +65,19 @@ export async function verifyPassword(password: string, hashInput: string): Promi
}); });
} }
export function randomString(length: number): string { export async function verifyJWT(token: string): Promise<boolean> {
return new Promise<boolean>(async (resolve, reject) => {
try {
jwtVerify(token, JWT_SECRET, { algorithms: ['HS256'] });
resolve(true);
} catch {
resolve(false);
}
});
}
export function randomString(length: number, characters: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'): string {
let result = ''; let result = '';
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const charactersLength = characters.length; const charactersLength = characters.length;
for ( let i = 0; i < length; i++ ) { for ( let i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength)); result += characters.charAt(Math.floor(Math.random() * charactersLength));

View File

@@ -1,170 +0,0 @@
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

@@ -1,5 +1,3 @@
import * as amqp from 'amqplib';
import { Schema, SchemaType } from 'mongoose';
import { logger, RABBITMQ_URI } from '../app'; import { logger, RABBITMQ_URI } from '../app';
import { Beat } from '../models/beat/beat.model.'; import { Beat } from '../models/beat/beat.model.';
import { ISeverity, NotificationType } from '../models/notifications/notification.interface'; import { ISeverity, NotificationType } from '../models/notifications/notification.interface';
@@ -63,7 +61,7 @@ export class RabbitMQ {
this.timeouts.delete(phone.id); this.timeouts.delete(phone.id);
phone.active = false; phone.active = false;
await phone.save(); await phone.save();
}, 60_000); }, 30_000);
this.timeouts.set(phone.id, timeoutTimer); this.timeouts.set(phone.id, timeoutTimer);
this.publish(phone.user.toString(), newBeat.toJSON(), 'beat'); this.publish(phone.user.toString(), newBeat.toJSON(), 'beat');

166
backend/lib/socketio.ts Normal file
View File

@@ -0,0 +1,166 @@
import * as socketio from "socket.io";
import { Server } from 'http';
import { JWT_SECRET, logger } from "../app";
import { randomString, verifyJWT } from "./crypto";
import { decode, sign } from "jsonwebtoken";
import { User } from "../models/user/user.model";
import { IPhone } from "../models/phone/phone.interface";
import { IUser } from "../models/user/user.interface";
import { Phone } from "../models/phone/phone.model";
/**
* This class handles all SocketIO connections.
*
* *SocketIO is another layer ontop of WebSockets*
*/
export class SocketManager {
io: socketio.Server;
/**
* Frontends have limited access to socket.io features. They just sit in connection and wait for any events.
*/
frontends: Array<string>;
/**
* A phone has some more privileges. They activly send new data and thus have write access.
*/
phones: Array<string>;
constructor(httpServer: Server) {
logger.debug("Preparing real-time communication ...");
this.frontends = [];
this.phones = [];
this.io = new socketio.Server();
this.io.listen(httpServer);
this.init();
}
getUserRoom(user: IUser) {
return `user-${user.id}`;
}
getUserFrontendRoom(user: IUser) {
return `user-${user.id}-frontend`;
}
getUserPhoneRoom(user: IUser) {
return `user-${user.id}-phone`;
}
init() {
this.io.on('connection', socket => {
socket.on('requestAccess', async data => {
data = JSON.parse(data);
let token: string = data.token;
let phone: IPhone = data.phone;
// If request is faulty or token invalid -> return.
if (data === undefined || phone === undefined) return;
if (await !verifyJWT(token)) return;
const id = decode(token, { json: true })!.user;
const user = await User.findById(id);
// If user doesn't exist -> return.
if (user === null) return;
const approvalCode = randomString(6, '0123456789');
// Create phone
const newPhone = await Phone.create({
...phone,
user,
approval: {
code: approvalCode
}
});
this.io.to(this.getUserRoom(user)).emit('approvePhone', newPhone);
// Respond with id so device can later submit correct code.
socket.emit('requestAccess', { phoneId: newPhone.id });
logger.info(`User ${user?.name} requests to connect new phone ${phone.displayName}`);
});
socket.on('submitPairCode', async data => {
const { phoneId, code } = JSON.parse(data);
console.log("Entry:", data, phoneId, code);
if (phoneId === undefined || code === undefined) return;
const phone = await Phone.findById(phoneId);
if (phone === null) return;
console.log(data, phoneId, code);
// If provided code isn't equal with actual code -> Emit event again.
if (phone.approval.code !== code) {
console.log(data, phoneId, code);
socket.emit('submitPairCode', '');
return;
}
phone.approval.approvedOn = new Date();
await phone.save();
// We're good. Create JWT token.
const token = sign({ user: phone.user._id, type: 'phone' }, JWT_SECRET, { expiresIn: '30d' });
socket.emit('submitPairCode', token);
});
socket.on('loginFrontend', async (token: string) => {
if (await verifyJWT(token)) {
const tokenDecoded = decode(token, { json: true });
const id = tokenDecoded!.user;
const type = tokenDecoded!.type;
const user = await User.findById(id);
if (user == null) return;
if (type != 'frontend') return;
if (this.frontends.indexOf(socket.id) != -1)
this.frontends.push(socket.id);
socket.join(this.getUserRoom(user));
socket.join(this.getUserFrontendRoom(user));
logger.info(`Socket ${socket.id} became a frontend socket.`);
}
});
socket.on('loginPhone', async (token: string) => {
if (await verifyJWT(token)) {
const tokenDecoded = decode(token, { json: true });
const id = tokenDecoded!.user;
const type = tokenDecoded!.type;
const user = await User.findById(id);
if (user == null) return;
if (type != 'phone') return;
if (this.frontends.indexOf(socket.id) != -1)
this.frontends.push(socket.id);
socket.join(this.getUserRoom(user));
socket.join(this.getUserPhoneRoom(user));
logger.info(`Socket ${socket.id} became a phone socket.`);
}
});
logger.info(`New socket connection from ${socket.handshake.address} with id ${socket.id} (total connections: ${this.io.sockets.sockets.size})`);
socket.emit('test', 'Yay, it works.');
});
}
}

View File

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

View File

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

View File

@@ -4,11 +4,14 @@ import { IUser } from '../user/user.interface';
export interface IPhone extends Document { export interface IPhone extends Document {
androidId: String, androidId: String,
displayName: String, displayName: String,
modelName: String, modelName: string,
operatingSystem: String, operatingSystem: String,
architecture: String, architecture: String,
user: IUser, user: IUser,
active: Boolean, approval: {
approvedOn?: Date,
code: String
},
updatedAt?: Date, updatedAt?: Date,
createdAt?: Date createdAt?: Date
} }

View File

@@ -8,7 +8,10 @@ const schemaPhone = new Schema({
operatingSystem: { type: String, required: false }, operatingSystem: { type: String, required: false },
architecture: { type: String, required: false }, architecture: { type: String, required: false },
user: { type: SchemaTypes.ObjectId, required: true }, user: { type: SchemaTypes.ObjectId, required: true },
active: { type: Boolean, required: true } approval: {
approvedOn: { type: Date, required: false },
code: { type: String, required: true }
}
}, { }, {
timestamps: { timestamps: {
createdAt: true, createdAt: true,

View File

@@ -12,7 +12,5 @@ export interface IUser extends Document {
salt: string, salt: string,
type: UserType, type: UserType,
lastLogin: Date, lastLogin: Date,
twoFASecret?: string, twoFASecret?: string
eventToken: string,
createdAt?: Date
} }

View File

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

4047
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,7 @@
"author": "Mondei1", "author": "Mondei1",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@types/node": "^14.14.9", "argon2": "^0.28.2",
"amqplib": "^0.6.0",
"argon2": "^0.27.0",
"bi-directional-map": "^1.0.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -29,13 +26,13 @@
"figlet": "^1.5.0", "figlet": "^1.5.0",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"moment": "^2.29.1", "moment": "^2.29.1",
"mongoose": "^5.10.9", "mongoose": "^5.13.7",
"socket.io": "^4.3.2",
"ts-node": "^9.0.0", "ts-node": "^9.0.0",
"typescript": "^4.0.3", "typescript": "^4.0.3",
"winston": "^3.3.3" "winston": "^3.3.3"
}, },
"devDependencies": { "devDependencies": {
"@types/amqplib": "0.5.14",
"@types/argon2": "0.15.0", "@types/argon2": "0.15.0",
"@types/body-parser": "1.19.0", "@types/body-parser": "1.19.0",
"@types/chalk": "2.2.0", "@types/chalk": "2.2.0",
@@ -46,6 +43,8 @@
"@types/jsonwebtoken": "8.5.0", "@types/jsonwebtoken": "8.5.0",
"@types/moment": "2.13.0", "@types/moment": "2.13.0",
"@types/mongoose": "5.7.36", "@types/mongoose": "5.7.36",
"@types/node": "^14.14.9",
"@types/socket.io": "^2.1.13",
"@types/typescript": "2.0.0", "@types/typescript": "2.0.0",
"@types/winston": "2.4.4", "@types/winston": "2.4.4",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",

3
backend_python/.idea/.gitignore generated vendored
View File

@@ -1,3 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml

View File

@@ -1,8 +0,0 @@
<?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

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

View File

@@ -1,7 +0,0 @@
<?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" />
<component name="PyCharmProfessionalAdvertiser">
<option name="shown" value="true" />
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?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>

View File

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

View File

@@ -1,17 +0,0 @@
from flask import Flask, jsonify
from flask_restful import Api, Resource, reqparse, abort
import pymongo, pika
import vars
import resources.user as user
app = Flask(__name__)
api = Api(app)
api.add_resource(user.UserLogin, "/user/login")
if __name__ == '__main__':
vars.db = pymongo.MongoClient("mongodb+srv://backend:Rjmzs75W9EYwW8G7@cluster0.qxerq.mongodb.net/livebeat?retryWrites=true&w=majority")
print(vars.db.list_databases())
rabbit = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = rabbit.channel()
app.run(debug=True)

View File

@@ -1,7 +0,0 @@
from flask_restful import Api, Resource, reqparse, abort
import vars
class UserLogin(Resource):
def get(self):
print(vars.db['livebeat'])
return

View File

@@ -1,6 +0,0 @@
"""
This file contains all variables that are shared across the entire application.
For example: Database connection, sockets, etc.
"""
db = None

27244
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,16 +11,16 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "~10.1.5", "@angular/animations": "~11.2.13",
"@angular/cdk": "^10.2.1", "@angular/cdk": "^10.2.1",
"@angular/common": "~10.1.5", "@angular/common": "~11.2.13",
"@angular/compiler": "~10.1.5", "@angular/compiler": "~11.2.13",
"@angular/core": "~10.1.5", "@angular/core": "~11.2.13",
"@angular/forms": "~10.1.5", "@angular/forms": "~11.2.13",
"@angular/platform-browser": "~10.1.5", "@angular/platform-browser": "~11.2.13",
"@angular/platform-browser-dynamic": "~10.1.5", "@angular/platform-browser-dynamic": "~11.2.13",
"@angular/router": "~10.1.5", "@angular/router": "~11.2.13",
"@angular/service-worker": "~10.1.5", "@angular/service-worker": "~11.2.13",
"@fortawesome/angular-fontawesome": "^0.7.0", "@fortawesome/angular-fontawesome": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^1.2.28", "@fortawesome/fontawesome-svg-core": "^1.2.28",
"@fortawesome/free-brands-svg-icons": "^5.13.0", "@fortawesome/free-brands-svg-icons": "^5.13.0",
@@ -31,19 +31,21 @@
"@types/moment": "^2.13.0", "@types/moment": "^2.13.0",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"eva-icons": "^1.1.3", "eva-icons": "^1.1.3",
"font-awesome": "^4.7.0",
"geojson": "^0.5.0", "geojson": "^0.5.0",
"mapbox-gl": "^1.12.0", "mapbox-gl": "^1.12.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"ng2-charts": "^2.4.2", "ng2-charts": "^2.4.2",
"ngx-mapbox-gl": "^4.8.1", "ngx-mapbox-gl": "^4.8.1",
"ngx-socket-io": "^4.1.0",
"rxjs": "~6.6.0", "rxjs": "~6.6.0",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"zone.js": "~0.10.2" "zone.js": "~0.10.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.1001.6", "@angular-devkit/build-angular": "~0.1102.12",
"@angular/cli": "~10.1.6", "@angular/cli": "~11.2.12",
"@angular/compiler-cli": "~10.1.5", "@angular/compiler-cli": "~11.2.13",
"@schematics/angular": "~10.1.6", "@schematics/angular": "~10.1.6",
"@types/jasmine": "~3.5.0", "@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3", "@types/jasminewd2": "~2.0.3",
@@ -51,7 +53,7 @@
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"jasmine-core": "~3.6.0", "jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0", "jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0", "karma": "~6.3.2",
"karma-chrome-launcher": "~3.1.0", "karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2", "karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { IPhone } from '../api.service';
export class Alert { export class Alert {
id: string; id: string;

View File

@@ -57,7 +57,13 @@
.alert-warning { .alert-warning {
background-color: #F3CC17 !important; background-color: #F3CC17 !important;
color: #000 !important; color: rgba(53, 92, 74, 0.8) !important;
}
.alert-pair {
height: 5rem !important;
background-color: rgba(62, 58, 143, 0.8) !important;
backdrop-filter: blur(20px);
} }
.fadeOut { .fadeOut {

View File

@@ -3,9 +3,9 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import * as moment from 'moment'; import * as moment from 'moment';
import { AlertService, AlertType } from './_alert/alert.service'; import { AlertService, AlertType } from './_alert/alert.service';
import { Socket } from 'ngx-socket-io';
/* /*
* ==========================
* DEFINITION OF TYPE * DEFINITION OF TYPE
*/ */
export interface ILogin { export interface ILogin {
@@ -43,7 +43,7 @@ export enum UserType {
export interface IUser { export interface IUser {
_id: string; _id: string;
name: string; name: string;
eventToken: string; brokerToken: string;
type: UserType; type: UserType;
lastLogin: Date; lastLogin: Date;
twoFASecret?: string; twoFASecret?: string;
@@ -57,6 +57,10 @@ export interface IPhone {
modelName: string; modelName: string;
operatingSystem: string; operatingSystem: string;
architecture: string; architecture: string;
approval: {
approvedOn?: Date,
code: String
},
user: IUser; user: IUser;
updatedAt?: Date; updatedAt?: Date;
createdAt?: Date; createdAt?: Date;
@@ -83,13 +87,8 @@ export interface INotification extends Document {
user: IUser; user: IUser;
} }
interface CustomEvent extends Event {
data: any;
}
/* /*
* END OF THE TYPE DEFINITION * END OF THE DEFINITION OF TYPE
* ==========================
*/ */
@Injectable({ @Injectable({
@@ -98,9 +97,9 @@ interface CustomEvent extends Event {
export class APIService { export class APIService {
private token: string; private token: string;
private events: EventSource | undefined;
username: string; username: string;
rabbitmq: any;
// Passthough data (not useful for api but a way for components to share data) // Passthough data (not useful for api but a way for components to share data)
showFilter = true; showFilter = true;
@@ -116,7 +115,7 @@ export class APIService {
user: IUser = { user: IUser = {
_id: '', _id: '',
name: '', name: '',
eventToken: '', brokerToken: '',
lastLogin: new Date(2020, 3, 1), lastLogin: new Date(2020, 3, 1),
type: UserType.GUEST, type: UserType.GUEST,
createdAt: new Date(), createdAt: new Date(),
@@ -130,68 +129,60 @@ export class APIService {
loginEvent: BehaviorSubject<boolean> = new BehaviorSubject(false); loginEvent: BehaviorSubject<boolean> = new BehaviorSubject(false);
fetchingDataEvent: BehaviorSubject<boolean> = new BehaviorSubject(false); fetchingDataEvent: BehaviorSubject<boolean> = new BehaviorSubject(false);
API_ENDPOINT = 'http://192.168.178.26:8040'; API_ENDPOINT = 'http://localhost:8040';
constructor(private httpClient: HttpClient, private alert: AlertService) { } constructor(private httpClient: HttpClient, private alert: AlertService, private socket: Socket) { }
/** private socketInit(): void {
* This functions opens a new http connection that stays open, forever, to receive events from the backend. // Connect with Socket.io after we received our user information
*/ this.socket.connect();
async subscribeToEvents() { this.socket.on('connect', () => {
let shownError = false; console.log("HERE: " + this.token);
// If there is already a event, close it. this.socket.emit('loginFrontend', this.token);
if (this.events !== undefined) { });
this.events.close();
}
this.events = new EventSource(`${this.API_ENDPOINT}/user/events?token=${this.user.eventToken}`); this.socket.on('test', data => {
this.alert.info(data);
console.log('Received test:', data);
});
this.events.onopen = event => { this.socket.on('approvePhone', (phone: IPhone) => {
console.info('Connection to event stream is open. Awaiting incoming events ...'); this.alert.dynamic(`To pair ${phone.displayName}, type in code: ${phone.approval.code}`, AlertType.INFO, 'Pair', { duration: 0 });
shownError = false; });
};
this.events.onerror = error => { /*this.mqtt.connect({
if (shownError) return; hostname: '192.168.178.26',
console.error('Connection to event stream has failed! Error: ' + error); port: 15675,
this.alert.error('Could not subscribe to events', 'Events'); protocol: 'ws',
path: '/ws',
username: this.user.name,
password: this.user.brokerToken
});
shownError = true; 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);
this.events.onmessage = async event => { if (obj.type === 'beat') {
const jsonData = JSON.parse(event.data);
console.debug(`[SSE] ${event.type}: ${event.data}`);
switch (event.type) {
case 'beat':
if (this.beats !== undefined) { if (this.beats !== undefined) {
this.beats.push(jsonData); this.beats.push(obj);
this.beatsEvent.next([jsonData]); // We just push one, so the map doesn't has to rebuild everything from scratch. this.beatsEvent.next([obj]); // We just push one, so the map doesn't has to rebuild everything from scratch.
this.beatStats.totalBeats++; this.beatStats.totalBeats++;
} }
} else if (obj.type === 'phone_available') {
console.debug('Received count:', jsonData); this.alert.dynamic(`Device ${obj.displayName} is now online`, obj.severity, 'Device');
break; } else if (obj.type === 'phone_register') {
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(); await this.getPhones();
this.alert.dynamic(`New device "${jsonData.displayName}"`, jsonData.severity, 'New device'); this.alert.dynamic(`New device "${obj.displayName}"`, obj.severity, 'New device');
break; } else if (obj.type === 'phone_alive') {
case 'phone_alive': this.alert.dynamic('Device is now active', obj.severity, obj.displayName);
this.alert.dynamic('Device is now active', jsonData.severity, jsonData.displayName); } else if (obj.type === 'phone_dead') {
break; this.alert.dynamic('Device is now offline', obj.severity, obj.displayName);
case 'phone_dead':
this.alert.dynamic('Device is now offline', jsonData.severity, jsonData.displayName);
break;
} }
}; }
});*/
} }
/* /*
@@ -199,7 +190,6 @@ export class APIService {
*/ */
async login(username: string, password: string): Promise<ILogin> { async login(username: string, password: string): Promise<ILogin> {
return new Promise<ILogin>(async (resolve, reject) => { 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' }) this.httpClient.post(this.API_ENDPOINT + '/user/login', { username, password }, { responseType: 'json' })
.subscribe(async token => { .subscribe(async token => {
console.log(token); console.log(token);
@@ -210,9 +200,9 @@ export class APIService {
await this.getUserInfo(); await this.getUserInfo();
await this.getNotifications(); await this.getNotifications();
this.subscribeToEvents(); this.socketInit();
await this.getBeats({ from: moment().startOf('day').unix(), to: moment().unix() }); await this.getBeats();
await this.getBeatStats(); await this.getBeatStats();
this.loginEvent.next(true); this.loginEvent.next(true);
this.alert.success('Login successful', 'Login', { duration: 2 }); this.alert.success('Login successful', 'Login', { duration: 2 });
@@ -414,7 +404,7 @@ export class APIService {
}); });
} }
/* HELPER FUNCTIONS */ /* HELPER CLASSES */
degreesToRadians(degrees: number): number { degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180; return degrees * Math.PI / 180;
} }
@@ -458,14 +448,7 @@ export class APIService {
return this.beats[this.beats.length - 1]; return this.beats[this.beats.length - 1];
} }
/**
* @returns `true` if user is logged in and `false` if not.
*/
hasSession(): boolean { hasSession(): boolean {
return this.token !== undefined; return this.token !== undefined;
} }
getToken(): string {
return this.token;
}
} }

View File

@@ -8,10 +8,10 @@
<li class="navbar-right"><a [routerLink]="['/notifications']"> <li class="navbar-right"><a [routerLink]="['/notifications']">
<img src="assets/message.svg"> <img src="assets/message.svg">
</a></li> </a></li>
<li class="navbar-right"><a [routerLink]="['/user', this.api.user._id]" <li class="navbar-right"><a [routerLink]="['/user', this.api.user._id]" routerLinkActive="router-link-active">
routerLinkActive="router-link-active">
<fa-icon [icon]="faUser"></fa-icon> <fa-icon [icon]="faUser"></fa-icon>
{{this.api.username}}</a></li> {{this.api.username}}
</a></li>
<li class="navbar-right"><a [routerLink]="['/admin']" routerLinkActive="router-link-active" <li class="navbar-right"><a [routerLink]="['/admin']" routerLinkActive="router-link-active"
*ngIf="this.api.user.type == 'admin'"> *ngIf="this.api.user.type == 'admin'">

View File

@@ -12,15 +12,17 @@
#header { #header {
position: fixed; position: fixed;
top: 0; top: 16px;
left: 0; left: 50%;
width: 100vw; transform: translateX(-50%);
width: 98vw;
height: fit-content; height: fit-content;
padding-top: 0.8rem; padding-top: 0.8rem;
padding-bottom: 0.8rem; padding-bottom: 0.8rem;
background-color: #1d1d1dd9; background-color: #1d1d1dd9;
backdrop-filter: blur(30px); backdrop-filter: blur(30px);
box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.85); box-shadow: 10px 10px 50px 0px rgba(0, 0, 0, 0.85);
border-radius: 8px;
& ul { & ul {
display: inline; display: inline;
@@ -84,5 +86,5 @@
} }
.header-spacer { .header-spacer {
height: 3rem; height: 4rem;
} }

View File

@@ -1,4 +1,4 @@
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
@@ -20,7 +20,12 @@ import { AdminComponent } from './admin/admin.component';
import { AlertComponent } from './_alert/alert/alert.component'; import { AlertComponent } from './_alert/alert/alert.component';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NotificationsComponent } from './notifications/notifications.component'; import { NotificationsComponent } from './notifications/notifications.component';
import { BackendInterceptor } from './interceptor'; import { SocketIoConfig, SocketIoModule } from 'ngx-socket-io';
const config: SocketIoConfig = { url: 'http://localhost:8040', options: {
transports: ['websocket'],
autoConnect: false
}};
@NgModule({ @NgModule({
declarations: [ declarations: [
@@ -47,15 +52,10 @@ import { BackendInterceptor } from './interceptor';
}), }),
ChartsModule, ChartsModule,
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }), ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }),
FontAwesomeModule FontAwesomeModule,
], SocketIoModule.forRoot(config)
providers: [
{
provide: HTTP_INTERCEPTORS,
useClass: BackendInterceptor,
multi: true
}
], ],
providers: [],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })
export class AppModule { } export class AppModule { }

View File

@@ -8,7 +8,6 @@
.dwidget { .dwidget {
line-height: 0.5rem; line-height: 0.5rem;
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(30px);
width: fit-content; width: fit-content;
padding: 1.5rem; padding: 1.5rem;
padding-right: 6rem !important; padding-right: 6rem !important;
@@ -25,8 +24,9 @@
.bgColor { .bgColor {
z-index: -1 !important; z-index: -1 !important;
position: absolute; position: absolute;
width: 8rem; width: 6rem;
height: 1rem; height: 1rem;
margin-left: 5px; margin-left: 20px;
filter: blur(20px);
background-color: transparent !important; background-color: transparent !important;
} }

View File

@@ -1,19 +0,0 @@
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

@@ -25,7 +25,7 @@ addEventListener('message', ({ data }) => {
progress++; progress++;
// Report progress every fifth loop // Report progress every fifth loop
if (Math.trunc(progress / data.length * 100) % 3 === 0) { if (Math.trunc(progress / data.length * 100) % 5 === 0) {
postMessage({ progress: (progress / data.length) * 100 }); postMessage({ progress: (progress / data.length) * 100 });
} }

View File

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

View File

@@ -6,9 +6,15 @@
<h2 *ngIf="showDevices">Devices</h2> <h2 *ngIf="showDevices">Devices</h2>
<ul class="phoneListing" *ngIf="showDevices"> <ul class="phoneListing" *ngIf="showDevices">
<li *ngFor="let phone of this.api.phones"> <li class="singlePhone" *ngFor="let phone of this.api.phones">
<h2 [ngClass]="{offline: !phone.active}">{{phone.displayName}} <span class="lastBeat">last beat was {{ this.lastBeats.get(phone._id) }}</span></h2> <img src="assets/phone.svg">
<p>{{phone.modelName}}</p> <h2 [ngClass]="{offline: !phone.active}">{{ phone.displayName }} <span class="smaller">last beat was {{ this.lastBeats.get(phone._id) }}</span></h2>
<p>{{phone.modelName}} <span class="smaller">| created at: {{ phone.createdAt }}</span></p>
<div *ngIf="phone.approval != null && phone.approval.approvedOn == null">
<small>Code</small>
<h3>{{ phone.approval.code }}</h3>
</div>
</li> </li>
</ul> </ul>
</div> </div>

View File

@@ -1,4 +1,4 @@
@import '../../styles.scss'; @import "../../styles.scss";
#user { #user {
min-width: 40rem; min-width: 40rem;
@@ -20,16 +20,53 @@
.phoneListing { .phoneListing {
list-style: none; list-style: none;
padding: 1.5rem; padding: 1rem;
border-radius: 10px; border-radius: 10px;
background-color: $darker; background-color: $darker;
} }
.offline { .singlePhone {
color: #ff6464 display: grid;
grid-template-columns: 64px 1fr 5rem;
grid-template-rows: 1fr 1fr;
column-gap: 10rem;
gap: 0px 0px;
width: 100%;
height: 100%;
& img {
transform: translateY(50%);
width: 100%;
grid-area: 1 / 1 / 3 / 2;
}
& h2 {
margin-left: 1rem;
grid-area: 1 / 2 / 2 / 3;
}
& p {
margin-left: 1rem;
grid-area: 2 / 2 / 3 / 3;
}
/* Dirty but that's the approval code. */
& div {
small {
grid-area: 1 / 3 / 2 / 4;
}
h3 {
grid-area: 2 / 3 / 3 / 4;
}
}
} }
.lastBeat { .offline {
color: #ff6464;
}
.smaller {
font-weight: lighter; font-weight: lighter;
font-size: 10pt; font-size: 10pt;
} }

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-device-mobile" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.25" stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="7" y="4" width="10" height="16" rx="1"></rect>
<line x1="11" y1="5" x2="13" y2="5"></line>
<line x1="12" y1="17" x2="12" y2="17.01"></line>
</svg>

After

Width:  |  Height:  |  Size: 453 B

165
logo2.svg
View File

@@ -1,165 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 4.9 KiB

1
node_modules Symbolic link
View File

@@ -0,0 +1 @@
frontend/node_modules

272
package-lock.json generated
View File

@@ -1,272 +0,0 @@
{
"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="
}
}
}