From d2503aa4e63e5a909dccc677fef5176b3d454ac2 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Fri, 8 Jan 2021 23:51:26 +0100 Subject: [PATCH] Properly implemented the connection history logger --- shared/js/ConnectionHandler.ts | 57 ++- shared/js/connectionlog/History.ts | 435 ++++++++++++++++++ .../PrivateConversationHistory.ts | 3 +- shared/js/tree/Server.ts | 26 +- shared/js/ui/modal/ModalConnect.ts | 1 - web/app/legacy/audio-lib/index.ts | 2 +- 6 files changed, 486 insertions(+), 38 deletions(-) create mode 100644 shared/js/connectionlog/History.ts diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index dc51b820..7d8abb10 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -40,6 +40,7 @@ import {SelectedClientInfo} from "./SelectedClientInfo"; import {SideBarManager} from "tc-shared/SideBarManager"; import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog"; import {PlaylistManager} from "tc-shared/music/PlaylistManager"; +import {connectionHistory} from "tc-shared/connectionlog/History"; export enum InputHardwareState { MISSING, @@ -138,6 +139,7 @@ export class ConnectionHandler { connection_state: ConnectionState = ConnectionState.UNCONNECTED; serverConnection: AbstractServerConnection; + currentConnectId: number; /* Id used for the connection history */ fileManager: FileManager; @@ -263,7 +265,7 @@ export class ConnectionHandler { this.autoReconnectAttempt = parameters.auto_reconnect_attempt || false; this.handleDisconnect(DisconnectReason.REQUESTED); - let server_address: ServerAddress = { + let resolvedAddress: ServerAddress = { host: "", port: -1 }; @@ -271,22 +273,22 @@ export class ConnectionHandler { let _v6_end = addr.indexOf(']'); let idx = addr.lastIndexOf(':'); if(idx != -1 && idx > _v6_end) { - server_address.port = parseInt(addr.substr(idx + 1)); - server_address.host = addr.substr(0, idx); + resolvedAddress.port = parseInt(addr.substr(idx + 1)); + resolvedAddress.host = addr.substr(0, idx); } else { - server_address.host = addr; - server_address.port = 9987; + resolvedAddress.host = addr; + resolvedAddress.port = 9987; } } - logInfo(LogCategory.CLIENT, tr("Start connection to %s:%d"), server_address.host, server_address.port); + logInfo(LogCategory.CLIENT, tr("Start connection to %s:%d"), resolvedAddress.host, resolvedAddress.port); this.log.log("connection.begin", { address: { - server_hostname: server_address.host, - server_port: server_address.port + server_hostname: resolvedAddress.host, + server_port: resolvedAddress.port }, client_nickname: parameters.nickname }); - this.channelTree.initialiseHead(addr, server_address); + this.channelTree.initialiseHead(addr, resolvedAddress); if(parameters.password && !parameters.password.hashed){ try { @@ -302,28 +304,28 @@ export class ConnectionHandler { } if(parameters.password) { connection_log.update_address_password({ - hostname: server_address.host, - port: server_address.port + hostname: resolvedAddress.host, + port: resolvedAddress.port }, parameters.password.password); } - const original_address = {host: server_address.host, port: server_address.port}; - if(server_address.host === "localhost") { - server_address.host = "127.0.0.1"; - } else if(dns.supported() && !server_address.host.match(Regex.IP_V4) && !server_address.host.match(Regex.IP_V6)) { + const originalAddress = {host: resolvedAddress.host, port: resolvedAddress.port}; + if(resolvedAddress.host === "localhost") { + resolvedAddress.host = "127.0.0.1"; + } else if(dns.supported() && !resolvedAddress.host.match(Regex.IP_V4) && !resolvedAddress.host.match(Regex.IP_V6)) { const id = ++this.connectAttemptId; this.log.log("connection.hostname.resolve", {}); try { - const resolved = await dns.resolve_address(server_address, { timeout: 5000 }) || {} as any; + const resolved = await dns.resolve_address(resolvedAddress, { timeout: 5000 }) || {} as any; if(id != this.connectAttemptId) return; /* we're old */ - server_address.host = typeof(resolved.target_ip) === "string" ? resolved.target_ip : server_address.host; - server_address.port = typeof(resolved.target_port) === "number" ? resolved.target_port : server_address.port; + resolvedAddress.host = typeof(resolved.target_ip) === "string" ? resolved.target_ip : resolvedAddress.host; + resolvedAddress.port = typeof(resolved.target_port) === "number" ? resolved.target_port : resolvedAddress.port; this.log.log("connection.hostname.resolved", { address: { - server_port: server_address.port, - server_hostname: server_address.host + server_port: resolvedAddress.port, + server_hostname: resolvedAddress.host } }); } catch(error) { @@ -334,14 +336,22 @@ export class ConnectionHandler { } } - await this.serverConnection.connect(server_address, new HandshakeHandler(profile, parameters)); + if(user_action) { + this.currentConnectId = await connectionHistory.logConnectionAttempt(originalAddress.host, originalAddress.port); + } else { + this.currentConnectId = -1; + } + + await this.serverConnection.connect(resolvedAddress, new HandshakeHandler(profile, parameters)); setTimeout(() => { const connected = this.serverConnection.connected(); if(user_action && connected) { connection_log.log_connect({ - hostname: original_address.host, - port: original_address.port + hostname: originalAddress.host, + port: originalAddress.port }); + + /* TODO: Log successful connect/update the server unique id: attemptId */ } }, 50); } @@ -491,6 +501,7 @@ export class ConnectionHandler { private _certificate_modal: Modal; handleDisconnect(type: DisconnectReason, data: any = {}) { this.connectAttemptId++; + this.currentConnectId = -1; let auto_reconnect = false; switch (type) { diff --git a/shared/js/connectionlog/History.ts b/shared/js/connectionlog/History.ts new file mode 100644 index 00000000..47f5613b --- /dev/null +++ b/shared/js/connectionlog/History.ts @@ -0,0 +1,435 @@ +import {LogCategory, logError, logWarn} from "tc-shared/log"; +import {tr} from "tc-shared/i18n/localize"; +import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; +import {ConnectionHandler} from "tc-shared/ConnectionHandler"; +import {server_connections} from "tc-shared/ConnectionManager"; +import {ServerProperties} from "tc-shared/tree/Server"; + +const kUnknownServerUniqueId = "unknown"; + +export type ConnectionHistoryEntry = { + id: number, + timestamp: number, + + targetHost: string, + targetPort: number, + + serverUniqueId: string | typeof kUnknownServerUniqueId +}; + +export type ConnectionHistoryServerEntry = { + firstConnectTimestamp: number, + firstConnectId: number, + + lastConnectTimestamp: number, + lastConnectId: number, +} + +export type ConnectionHistoryServerInfo = { + name: string, + iconId: number, + + /* These properties are only available upon server variable retrieval */ + clientsOnline: number | -1, + clientsMax: number | -1, + + passwordProtected: boolean +} + +export class ConnectionHistory { + private database: IDBDatabase; + + constructor() { } + + async initializeDatabase() { + const openRequest = indexedDB.open("connection-log", 1); + openRequest.onupgradeneeded = event => { + const database = openRequest.result; + switch (event.oldVersion) { + case 0: + if(!database.objectStoreNames.contains("attempt-history")) { + const store = database.createObjectStore("attempt-history", { keyPath: "id", autoIncrement: true }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("targetHost", "targetHost", { unique: false }); + store.createIndex("targetPort", "targetPort", { unique: false }); + store.createIndex("serverUniqueId", "serverUniqueId", { unique: false }); + } + + if(!database.objectStoreNames.contains("server-info")) { + const store = database.createObjectStore("server-info", { keyPath: "uniqueId" }); + store.createIndex("firstConnectTimestamp", "firstConnectTimestamp", { unique: false }); + store.createIndex("firstConnectId", "firstConnectId", { unique: false }); + + store.createIndex("lastConnectTimestamp", "lastConnectTimestamp", { unique: false }); + store.createIndex("lastConnectId", "lastConnectId", { unique: false }); + + store.createIndex("name", "name", { unique: false }); + store.createIndex("iconId", "iconId", { unique: false }); + + store.createIndex("clientsOnline", "clientsOnline", { unique: false }); + store.createIndex("clientsMax", "clientsMax", { unique: false }); + + store.createIndex("passwordProtected", "passwordProtected", { unique: false }); + } + + /* fall through wanted */ + case 1: + break; + + default: + throw tra("connection log database has an invalid version: {}", event.oldVersion); + } + }; + + this.database = await new Promise((resolve, reject) => { + openRequest.onblocked = () => reject(tr("Failed to open the connection log database")); + + openRequest.onerror = () => { + logError(LogCategory.GENERAL, tr("Failed to open the client connection log database: %o"), openRequest.error); + reject(openRequest.error.message); + }; + + openRequest.onsuccess = () => resolve(openRequest.result); + }); + } + + /** + * Register a new connection attempt. + * @param targetHost + * @param targetPort + * @return Returns a unique connect attempt identifier id which could be later used to set the unique server id. + */ + async logConnectionAttempt(targetHost: string, targetPort: number) : Promise { + if(!this.database) { + return; + } + + const transaction = this.database.transaction(["attempt-history"], "readwrite"); + const store = transaction.objectStore("attempt-history"); + + const id = await new Promise((resolve, reject) => { + const insert = store.put({ + timestamp: Date.now(), + targetHost: targetHost, + targetPort: targetPort, + serverUniqueId: kUnknownServerUniqueId + }); + + insert.onsuccess = () => resolve(insert.result); + insert.onerror = () => reject(insert.error); + }); + + if(typeof id !== "number") { + logError(LogCategory.GENERAL, tr("Received invalid idb key type which isn't a number: %o"), id); + throw tr("invalid idb key returned"); + } + + return id; + } + + private async resolveDatabaseServerInfo(serverUniqueId: string) : Promise { + const transaction = this.database.transaction(["server-info"], "readwrite"); + const store = transaction.objectStore("server-info"); + + return await new Promise((resolve, reject) => { + const cursor = store.openCursor(serverUniqueId); + cursor.onsuccess = () => resolve(cursor.result); + cursor.onerror = () => reject(cursor.error); + }); + } + + private async updateDatabaseServerInfo(serverUniqueId: string, updateCallback: (databaseValue) => void) { + let entry = await this.resolveDatabaseServerInfo(serverUniqueId); + + if(entry) { + const newValue = Object.assign({}, entry.value); + updateCallback(newValue); + await new Promise((resolve, reject) => { + const update = entry.update(newValue); + update.onsuccess = resolve; + update.onerror = () => reject(update.error); + }); + } else { + const transaction = this.database.transaction(["server-info"], "readwrite"); + const store = transaction.objectStore("server-info"); + + const value = { + uniqueId: serverUniqueId, + + firstConnectTimestamp: 0, + firstConnectId: -1, + + lastConnectTimestamp: 0, + lastConnectId: -1, + + name: tr("unknown"), + iconId: 0, + + clientsOnline: -1, + clientsMax: -1, + + passwordProtected: false + }; + + updateCallback(value); + await new Promise((resolve, reject) => { + const insert = store.put(value); + insert.onsuccess = resolve; + insert.onerror = () => reject(insert.error); + }); + } + } + + /** + * Update the connection attempts target server id. + * @param connectionAttemptId + * @param serverUniqueId + */ + async updateConnectionServerUniqueId(connectionAttemptId: number, serverUniqueId: string) { + if(!this.database) { + return; + } + + const transaction = this.database.transaction(["attempt-history"], "readwrite"); + const store = transaction.objectStore("attempt-history"); + + let connectAttemptInfo; + { + const entry = await new Promise((resolve, reject) => { + const cursor = store.openCursor(connectionAttemptId); + cursor.onsuccess = () => resolve(cursor.result); + cursor.onerror = () => reject(cursor.error); + }); + + if(!entry) { + throw tr("missing connection attempt"); + } + + if(entry.value.serverUniqueId === serverUniqueId) { + logWarn(LogCategory.GENERAL, tr("updateConnectionServerUniqueId(...) has been called twice")); + return; + } else if(entry.value.serverUniqueId !== kUnknownServerUniqueId) { + throw tr("connection attempt has already a server unique id set"); + } + + const newValue = connectAttemptInfo = Object.assign({}, entry.value); + newValue.serverUniqueId = serverUniqueId; + + await new Promise((resolve, reject) => { + const update = entry.update(newValue); + update.onsuccess = resolve; + update.onerror = () => reject(update.error); + }); + } + + await this.updateDatabaseServerInfo(serverUniqueId, databaseValue => { + if(databaseValue.firstConnectTimestamp === 0) { + databaseValue.firstConnectTimestamp = connectAttemptInfo.timestamp; + databaseValue.firstConnectId = connectAttemptInfo.id; + } + + databaseValue.lastConnectTimestamp = connectAttemptInfo.timestamp; + databaseValue.lastConnectId = connectAttemptInfo.id; + }); + } + + /** + * Update the server info of the given server. + * @param serverUniqueId + * @param info + */ + async updateServerInfo(serverUniqueId: string, info: ConnectionHistoryServerInfo) { + if(!this.database) { + return; + } + + + await this.updateDatabaseServerInfo(serverUniqueId, databaseValue => { + databaseValue.name = info.name; + databaseValue.iconId = info.iconId; + + databaseValue.clientsOnline = info.clientsOnline; + databaseValue.clientsMax = info.clientsMax; + }); + } + + /** + * Query the server info of a given server unique id + * @param serverUniqueId + */ + async queryServerInfo(serverUniqueId: string) : Promise<(ConnectionHistoryServerInfo & ConnectionHistoryServerEntry) | undefined> { + if(!this.database) { + return undefined; + } + + let entry = await this.resolveDatabaseServerInfo(serverUniqueId); + if(!entry) { + return; + } + + const value = entry.value; + return { + firstConnectId: value.firstConnectId, + firstConnectTimestamp: value.firstConnectTimestamp, + + lastConnectId: value.lastConnectId, + lastConnectTimestamp: value.lastConnectTimestamp, + + name: value.name, + iconId: value.iconId, + + clientsOnline: value.clientsOnline, + clientsMax: value.clientsMax, + + passwordProtected: value.passwordProtected + }; + } + + /** + * Query the last connected addresses/servers. + * @param maxUniqueServers + */ + async lastConnectedServers(maxUniqueServers: number) : Promise { + if(!this.database) { + return []; + } + + const result: ConnectionHistoryEntry[] = []; + + const transaction = this.database.transaction(["attempt-history"], "readwrite"); + const store = transaction.objectStore("attempt-history"); + + const cursor = store.index("timestamp").openCursor(undefined, "prev"); + while(result.length < maxUniqueServers) { + const entry = await new Promise((resolve, reject) => { + cursor.onsuccess = () => resolve(cursor.result); + cursor.onerror = () => reject(cursor.error); + }); + + if(!entry) { + break; + } + + const parsedEntry = { + id: entry.value.id, + timestamp: entry.value.timestamp, + + targetHost: entry.value.targetHost, + targetPort: entry.value.targetPort, + + serverUniqueId: entry.value.serverUniqueId, + } as ConnectionHistoryEntry; + entry.continue(); + + if(parsedEntry.serverUniqueId !== kUnknownServerUniqueId) { + if(result.findIndex(entry => entry.serverUniqueId === parsedEntry.serverUniqueId) !== -1) { + continue; + } + } else { + if(result.findIndex(entry => { + return entry.targetHost === parsedEntry.targetHost && entry.targetPort === parsedEntry.targetPort; + }) !== -1) { + continue; + } + } + + result.push(parsedEntry); + } + + return result; + } +} + +const kConnectServerInfoUpdatePropertyKeys: (keyof ServerProperties)[] = [ + "virtualserver_icon_id", + "virtualserver_name", + "virtualserver_flag_password", + "virtualserver_maxclients", + "virtualserver_clientsonline", + "virtualserver_flag_password" +]; + +class ConnectionHistoryUpdateListener { + private readonly history: ConnectionHistory; + + private listenerHandlerManager: (() => void)[]; + private listenerConnectionHandler: {[key: string]: (() => void)[]} = {}; + + constructor(history: ConnectionHistory) { + this.history = history; + this.listenerHandlerManager = []; + + this.listenerHandlerManager.push(server_connections.events().on("notify_handler_created", event => { + this.registerConnectionHandler(event.handler); + })); + + this.listenerHandlerManager.push(server_connections.events().on("notify_handler_deleted", event => { + this.listenerConnectionHandler[event.handler.handlerId]?.forEach(callback => callback()); + delete this.listenerConnectionHandler[event.handler.handlerId]; + })); + } + + destroy() { + this.listenerHandlerManager.forEach(callback => callback()); + + Object.values(this.listenerConnectionHandler).forEach(callbacks => callbacks.forEach(callback => callback())); + this.listenerConnectionHandler = {}; + } + + private registerConnectionHandler(handler: ConnectionHandler) { + handler.channelTree.server.events.on("notify_properties_updated", event => { + if("virtualserver_unique_identifier" in event.updated_properties) { + if(handler.currentConnectId > 0) { + this.history.updateConnectionServerUniqueId(handler.currentConnectId, event.server_properties.virtualserver_unique_identifier) + .catch(error => { + logError(LogCategory.GENERAL, tr("Failed to update connect server unique id: %o"), error); + }) + } + } + + for(const key of kConnectServerInfoUpdatePropertyKeys) { + if(key in event.updated_properties) { + this.history.updateServerInfo(event.server_properties.virtualserver_unique_identifier, { + name: event.server_properties.virtualserver_name, + iconId: event.server_properties.virtualserver_icon_id, + + clientsMax: event.server_properties.virtualserver_maxclients, + clientsOnline: event.server_properties.virtualserver_clientsonline, + + passwordProtected: event.server_properties.virtualserver_flag_password + }).catch(error => { + logError(LogCategory.GENERAL, tr("Failed to update connect server info: %o"), error); + }); + break; + } + } + }); + } +} + +export let connectionHistory: ConnectionHistory; +let historyInfoListener: ConnectionHistoryUpdateListener; + +loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { + priority: 0, + name: "Chat history setup", + function: async () => { + if(!('indexedDB' in window)) { + loader.critical_error(tr("Missing Indexed DB support")); + throw tr("Missing Indexed DB support"); + } + + connectionHistory = new ConnectionHistory(); + try { + await connectionHistory.initializeDatabase(); + } catch (error) { + logError(LogCategory.GENERAL, tr("Failed to initialize connection history database: %o"), error); + logError(LogCategory.GENERAL, tr("Do not saving the connection attempts.")); + return; + } + + historyInfoListener = new ConnectionHistoryUpdateListener(connectionHistory); + (window as any).connectionHistory = connectionHistory; + } +}); \ No newline at end of file diff --git a/shared/js/conversations/PrivateConversationHistory.ts b/shared/js/conversations/PrivateConversationHistory.ts index 9fc1be66..7f2349c4 100644 --- a/shared/js/conversations/PrivateConversationHistory.ts +++ b/shared/js/conversations/PrivateConversationHistory.ts @@ -206,8 +206,9 @@ async function doOpenDatabase(forceUpgrade: boolean) { function doInitializeUser(uniqueId: string, database: IDBDatabase) { const storeId = clientUniqueId2StoreName(uniqueId); - if(database.objectStoreNames.contains(storeId)) + if(database.objectStoreNames.contains(storeId)) { return; + } const store = database.createObjectStore(storeId, { keyPath: "databaseId", autoIncrement: true }); diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index 4c7bcea8..82312e97 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -124,7 +124,7 @@ export interface ServerAddress { export interface ServerEvents extends ChannelTreeEntryEvents { notify_properties_updated: { - updated_properties: {[Key in keyof ServerProperties]: ServerProperties[Key]}; + updated_properties: Partial; server_properties: ServerProperties } } @@ -267,24 +267,26 @@ export class ServerEntry extends ChannelTreeEntry { log.table(LogType.DEBUG, LogCategory.PERMISSIONS, "Server update properties", entries); } - let update_bookmarks = false; + let updatedProperties: Partial = {}; + let update_bookmarks = false; for(let variable of variables) { - JSON.map_field_to(this.properties, variable.value, variable.key); + if(!JSON.map_field_to(this.properties, variable.value, variable.key)) { + /* The value has not been updated */ + continue; + } + updatedProperties[variable.key] = variable.value; if(variable.key == "virtualserver_icon_id") { - /* For more detail lookup client::updateVariables and client_icon_id! - * ATTENTION: This is required! - */ this.properties.virtualserver_icon_id = variable.value as any >>> 0; update_bookmarks = true; } } - { - let properties = {}; - for(const property of variables) - properties[property.key] = this.properties[property.key]; - this.events.fire("notify_properties_updated", { updated_properties: properties as any, server_properties: this.properties }); - } + + this.events.fire("notify_properties_updated", { + updated_properties: updatedProperties, + server_properties: this.properties + }); + if(update_bookmarks) { const bmarks = bookmarks.bookmarks_flat() .filter(e => e.server_properties.server_address === this.remote_address.host && e.server_properties.server_port == this.remote_address.port) diff --git a/shared/js/ui/modal/ModalConnect.ts b/shared/js/ui/modal/ModalConnect.ts index 046aea1a..52068386 100644 --- a/shared/js/ui/modal/ModalConnect.ts +++ b/shared/js/ui/modal/ModalConnect.ts @@ -4,7 +4,6 @@ import {LogCategory} from "../../log"; import * as loader from "tc-loader"; import {createModal} from "../../ui/elements/Modal"; import {ConnectionProfile, defaultConnectProfile, findConnectProfile, availableConnectProfiles} from "../../profiles/ConnectionProfile"; -import {KeyCode} from "../../PPTListener"; import * as i18nc from "../../i18n/country"; import {spawnSettingsModal} from "../../ui/modal/ModalSettings"; import {server_connections} from "tc-shared/ConnectionManager"; diff --git a/web/app/legacy/audio-lib/index.ts b/web/app/legacy/audio-lib/index.ts index f72c3298..c68d01a4 100644 --- a/web/app/legacy/audio-lib/index.ts +++ b/web/app/legacy/audio-lib/index.ts @@ -22,7 +22,7 @@ export class AudioLibrary { } private static spawnNewWorker() : Worker { - /* + /* * Attention don't use () => new Worker(...). * This confuses the worker plugin and will not emit any modules */