TeaWeb/shared/js/connectionlog/History.ts

634 lines
23 KiB
TypeScript

import {LogCategory, logError, logWarn} from "tc-shared/log";
import {tr, tra} from "tc-shared/i18n/localize";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {server_connections} from "tc-shared/ConnectionManager";
import {ServerProperties} from "tc-shared/tree/Server";
import {Registry} from "tc-events";
export const kUnknownHistoryServerUniqueId = "unknown";
export type ConnectionHistoryEntry = {
id: number,
timestamp: number,
serverUniqueId: string | typeof kUnknownHistoryServerUniqueId
/* Target address how it has been given by the user */
targetAddress: string,
nickname: string,
hashedPassword: string,
};
export type ConnectionHistoryServerEntry = {
firstConnectTimestamp: number,
firstConnectId: number,
lastConnectTimestamp: number,
lastConnectId: number,
}
export type ConnectionHistoryServerInfo = {
name: string,
iconId: number,
country: string,
/* These properties are only available upon server variable retrieval */
clientsOnline: number | -1,
clientsMax: number | -1,
hostBannerUrl: string | undefined,
hostBannerMode: number,
passwordProtected: boolean
}
export interface ConnectionHistoryEvents {
notify_server_info_updated: { serverUniqueId: string, keys: (keyof ConnectionHistoryServerInfo)[] }
}
export class ConnectionHistory {
readonly events: Registry<ConnectionHistoryEvents>;
private database: IDBDatabase;
constructor() {
this.events = new Registry<ConnectionHistoryEvents>();
}
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")) {
/*
Schema:
{
timestamp: number,
targetAddress: string,
nickname: string,
hashedPassword: string,
serverUniqueId: string | typeof kUnknownHistoryServerUniqueId,
}
*/
const store = database.createObjectStore("attempt-history", { keyPath: "id", autoIncrement: true });
store.createIndex("timestamp", "timestamp", { unique: false });
store.createIndex("targetAddress", "targetAddress", { unique: false });
store.createIndex("serverUniqueId", "serverUniqueId", { unique: false });
}
if(!database.objectStoreNames.contains("server-info")) {
database.createObjectStore("server-info", { keyPath: "uniqueId" });
/*
Schema:
{
firstConnectTimestamp: number,
firstConnectId: number,
lastConnectTimestamp: number,
lastConnectId: number,
name: string,
iconId: number,
country: string,
clientsOnline: number | -1,
clientsMax: number | -1,
passwordProtected: boolean
}
*/
}
/* fall through wanted */
case 1:
break;
default:
throw tra("connection log database has an invalid version: {}", event.oldVersion);
}
};
this.database = await new Promise<IDBDatabase>((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 attempt
* @return Returns a unique connect attempt identifier id which could be later used to set the unique server id.
*/
async logConnectionAttempt(attempt: {
targetAddress: string,
nickname: string,
hashedPassword: string,
}) : Promise<number> {
if(!this.database) {
return;
}
const transaction = this.database.transaction(["attempt-history"], "readwrite");
const store = transaction.objectStore("attempt-history");
const id = await new Promise<IDBValidKey>((resolve, reject) => {
const insert = store.put({
timestamp: Date.now(),
serverUniqueId: kUnknownHistoryServerUniqueId,
targetAddress: attempt.targetAddress,
nickname: attempt.nickname,
hashedPassword: attempt.hashedPassword,
});
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, mode: IDBTransactionMode) : Promise<IDBCursorWithValue | null> {
const transaction = this.database.transaction(["server-info"], mode);
const store = transaction.objectStore("server-info");
return await new Promise<IDBCursorWithValue | null>((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, "readwrite");
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<IDBCursorWithValue | null>((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 !== kUnknownHistoryServerUniqueId) {
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 connection attempt server password
* @param connectionAttemptId
* @param passwordHash
*/
async updateConnectionServerPassword(connectionAttemptId: number, passwordHash: string) {
if(!this.database) {
return;
}
const transaction = this.database.transaction(["attempt-history"], "readwrite");
const store = transaction.objectStore("attempt-history");
const entry = await new Promise<IDBCursorWithValue | null>((resolve, reject) => {
const cursor = store.openCursor(connectionAttemptId);
cursor.onsuccess = () => resolve(cursor.result);
cursor.onerror = () => reject(cursor.error);
});
if(!entry) {
throw tr("missing connection attempt");
}
const newValue = Object.assign({}, entry.value);
newValue.hashedPassword = passwordHash;
await new Promise((resolve, reject) => {
const update = entry.update(newValue);
update.onsuccess = resolve;
update.onerror = () => reject(update.error);
});
}
/**
* 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 => {
const changes: (keyof ConnectionHistoryServerInfo)[] = [];
const updateValue = (databaseKey: string, infoKey: keyof ConnectionHistoryServerInfo) => {
if(databaseValue[databaseKey] === info[infoKey]) {
return;
}
databaseValue[databaseKey] = info[infoKey];
changes.push(infoKey);
}
updateValue("name", "name");
updateValue("iconId", "iconId");
updateValue("clientsOnline", "clientsOnline");
updateValue("clientsMax", "clientsMax");
updateValue("hostBannerUrl", "hostBannerUrl");
updateValue("hostBannerMode", "hostBannerMode");
if(changes.length > 0) {
this.events.fire("notify_server_info_updated", { serverUniqueId: serverUniqueId, keys: changes });
}
});
}
async deleteConnectionAttempts(target: string, targetType: "address" | "server-unique-id") {
if(!this.database) {
return;
}
const transaction = this.database.transaction(["attempt-history"], "readwrite");
const store = transaction.objectStore("attempt-history");
const cursor = store.index(targetType === "server-unique-id" ? "serverUniqueId" : "targetAddress").openCursor(target);
const promises = [];
while(true) {
const entry = await new Promise<IDBCursorWithValue | null>((resolve, reject) => {
cursor.onsuccess = () => resolve(cursor.result);
cursor.onerror = () => reject(cursor.error);
});
if (!entry) {
break;
}
promises.push(new Promise<void>(resolve => {
const deleteRequest = entry.delete();
deleteRequest.onsuccess = () => resolve();
deleteRequest.onerror = () => {
logWarn(LogCategory.GENERAL, tr("Failed to delete a connection attempt: %o"), deleteRequest.error);
resolve();
}
}));
entry.continue();
}
await Promise.all(promises);
}
/**
* 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, "readonly");
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,
country: value.country,
clientsOnline: value.clientsOnline,
clientsMax: value.clientsMax,
hostBannerUrl: value.hostBannerUrl,
hostBannerMode: typeof value.hostBannerMode === "number" ? value.hostBannerMode : 0,
passwordProtected: value.passwordProtected
};
}
/**
* Query the last connected addresses/servers.
* @param maxUniqueServers
*/
async lastConnectedServers(maxUniqueServers: number) : Promise<ConnectionHistoryEntry[]> {
if(!this.database) {
return [];
}
const result: ConnectionHistoryEntry[] = [];
const transaction = this.database.transaction(["attempt-history"], "readonly");
const store = transaction.objectStore("attempt-history");
const cursor = store.index("timestamp").openCursor(undefined, "prev");
while(result.length < maxUniqueServers) {
const entry = await new Promise<IDBCursorWithValue | null>((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,
serverUniqueId: entry.value.serverUniqueId,
nickname: entry.value.nickname,
hashedPassword: entry.value.hashedPassword,
targetAddress: entry.value.targetAddress,
} as ConnectionHistoryEntry;
entry.continue();
if(parsedEntry.serverUniqueId !== kUnknownHistoryServerUniqueId) {
if(result.findIndex(entry => entry.serverUniqueId === parsedEntry.serverUniqueId) !== -1) {
continue;
}
const failedEntry = result.find(entry => entry.targetAddress === parsedEntry.targetAddress);
if(failedEntry) {
/* We've a newer, but failed attempt to that address. Since we've connected to that address already we could just use that attempt */
failedEntry.serverUniqueId = parsedEntry.serverUniqueId;
continue;
}
} else {
if(result.findIndex(entry => entry.targetAddress === parsedEntry.targetAddress) !== -1) {
continue;
}
}
result.push(parsedEntry);
}
return result;
}
async lastConnectInfo(target: string, targetType: "address" | "server-unique-id", onlySucceeded?: boolean) : Promise<ConnectionHistoryEntry | undefined> {
if(!this.database) {
return undefined;
}
const transaction = this.database.transaction(["attempt-history"], "readonly");
const store = transaction.objectStore("attempt-history");
const cursor = store.index(targetType === "server-unique-id" ? "serverUniqueId" : "targetAddress").openCursor(target, "prev");
while(true) {
const entry = await new Promise<IDBCursorWithValue | null>((resolve, reject) => {
cursor.onsuccess = () => resolve(cursor.result);
cursor.onerror = () => reject(cursor.error);
});
if(!entry) {
return undefined;
}
if(entry.value.serverUniqueId === kUnknownHistoryServerUniqueId && onlySucceeded) {
entry.continue();
continue;
}
return {
id: entry.value.id,
timestamp: entry.value.timestamp,
serverUniqueId: entry.value.serverUniqueId,
nickname: entry.value.nickname,
hashedPassword: entry.value.hashedPassword,
targetAddress: entry.value.targetAddress,
};
}
}
async countConnectCount(target: string, targetType: "address" | "server-unique-id") : Promise<number> {
if(!this.database) {
return -1;
}
const transaction = this.database.transaction(["attempt-history"], "readonly");
const store = transaction.objectStore("attempt-history");
const count = store.index(targetType === "server-unique-id" ? "serverUniqueId" : "targetAddress").count(target);
return await new Promise<number>((resolve, reject) => {
count.onsuccess = () => resolve(count.result);
count.onerror = () => reject(count.error);
});
}
}
const kConnectServerInfoUpdatePropertyKeys: (keyof ServerProperties)[] = [
"virtualserver_icon_id",
"virtualserver_name",
"virtualserver_flag_password",
"virtualserver_maxclients",
"virtualserver_clientsonline",
"virtualserver_flag_password",
"virtualserver_country_code",
"virtualserver_hostbanner_gfx_url",
"virtualserver_hostbanner_mode"
];
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) {
const events = this.listenerConnectionHandler[handler.handlerId] = [];
events.push(handler.channelTree.server.events.on("notify_properties_updated", event => {
switch(handler.connection_state) {
case ConnectionState.UNCONNECTED:
case ConnectionState.DISCONNECTING:
/* We don't want any changes here */
return;
}
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,
country: event.server_properties.virtualserver_country_code,
clientsMax: event.server_properties.virtualserver_maxclients,
clientsOnline: event.server_properties.virtualserver_clientsonline,
hostBannerUrl: event.server_properties.virtualserver_hostbanner_gfx_url,
hostBannerMode: event.server_properties.virtualserver_hostbanner_mode,
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: 40,
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;
}
});