import * as crypto from 'crypto'; import * as moment from 'moment'; import sizeOf from 'image-size'; import * as FileType from 'file-type'; import * as sharp from 'sharp'; import * as socketio from 'socket.io'; import Logger from '../logger/logger'; import DB from './db'; const LOG = Logger.create('db'); const MAX_TEXT_MESSAGE_LENGTH = 1024 * 2; // 2 KB character message max const MAX_RESOURCE_SIZE = 1024 * 1024 * 50; // 50 MB max resource size const MAX_AVATAR_SIZE = 1024 * 128; // 128 KB max avatar size const MAX_DISPLAY_NAME_LENGTH = 32; // 32 char max display name length const MAX_CHANNEL_NAME_LENGTH = 32; // 32 char max channel name length const MAX_CHANNEL_FLAVOR_TEXT_LENGTH = 256; // 256 char max channel flavor text length const MAX_ICON_SIZE = 1024 * 128; // 128 KB max server icon size const MAX_SERVER_NAME_LENGTH = 64; // 64 char max server name length interface IIdentity { serverId: string | null; memberId: string | null; verified: boolean; publicKey: crypto.KeyObject | null; challenge: Buffer | null } interface IChecks { verified?: boolean; privileges?: string[]; } function formatDate(date: Date): string { return moment(date).format('YYYY-MM-DD HH:mm:ss'); } let connected: IIdentity[] = []; class EventError extends Error { public cause: Error | undefined; public extended_message: string | undefined; constructor(message: string, cause?: Error, extended_message?: string) { super(message); this.name = 'EventError'; this.cause = cause; this.extended_message = extended_message; } } class SignatureError extends Error { constructor(message: string) { super(message); this.name = 'SignatureError'; } } function bindEvent( client: socketio.Socket, identity: IIdentity | null, checks: IChecks | null, name: string, signature: string[], handler: ((...args: any[]) => Promise) ) { client.on(name, async (...args) => { let respond = (_result: string) => {}; try { if (signature.length != args.length) { throw new SignatureError('invalid length'); } for (let i = 0; i < signature.length; ++i) { if (signature[i] == 'buffer') { if (!Buffer.isBuffer(args[i])) { throw new SignatureError('invalid types (buffer expected)'); } } else if (signature[i].endsWith('?')) { if (args[i] !== null && typeof args[i] !== signature[i].slice(0, -1)) { throw new SignatureError('invalid types (nullable)'); } } else { if (typeof args[i] !== signature[i]) { throw new SignatureError('invalid types (not nullable)'); } } } if (signature[signature.length - 1] === 'function') { respond = args[args.length - 1]; } if (checks && checks.verified && (!identity || !identity.verified)) { throw new EventError('not verified'); } if (checks && checks.privileges) { for (let privilege of checks.privileges) { if (!identity || !identity.serverId || !identity.memberId) { throw new EventError('not authorized'); } let hasPrivilege: boolean; try { hasPrivilege = await DB.hasPrivilege(identity.serverId, identity.memberId, privilege); } catch (e) { throw new EventError('not authorized', e, 'unable to check privilege'); } if (!hasPrivilege) { throw new EventError('not authorized'); } } } //LOG.debug(`c#${client.id}: ${name}`); try { await handler(...args); } catch (e) { if (e instanceof EventError) { throw e; } else { throw new EventError(`unable to handle ${name} request`, e); } } } catch (e) { if (e instanceof SignatureError) { LOG.warn(`c#${client.id}: ${name} request does not match expected signature`, { signature, args, msg: e.message }); // do not respond to requests with invalid signatures } else if (e instanceof EventError) { LOG.warn(`c#${client.id}: ${e.message}${e.extended_message ? ' / ' + e.extended_message : ''}`, e.cause); respond(e.message); } else { LOG.error('caught unhandled error', e); } } }) } function bindRegistrationEvents(io: socketio.Server, client: socketio.Socket): void { // non-identity events /* * Register for a server with a token * @param token The token sent in the .cordis file * @param publicKeyBuff The client's public key * @param displayName The client's display name * @param avatarBuff The client's avatar image * @param respond function(errStr, member) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.member The member created by the registration */ bindEvent( client, null, null, 'register-with-token', [ 'string', 'buffer', 'string', 'buffer', 'function' ], async (token, publicKeyBuff, displayName, avatarBuff, respond) => { if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length == 0) { throw new EventError('invalid display name'); } if (avatarBuff.length > MAX_AVATAR_SIZE) { throw new EventError('invalid avatar'); } let typeResult = await FileType.fromBuffer(avatarBuff); if (!typeResult || ['image/png', 'image/jpeg', 'image/jpg'].indexOf(typeResult.mime) == -1) { throw new EventError('invalid avatar mime type'); } if (!(await DB.isTokenReal(token))) { throw new EventError('not a real token'); } if (await DB.isTokenTaken(token)) { throw new EventError('token already used'); } if (!(await DB.isTokenActive(token))) { throw new EventError('token expired'); } const { serverId, memberId } = await DB.registerWithToken(token, publicKeyBuff, displayName, avatarBuff); const member = await DB.getMember(serverId, memberId); LOG.info(`c#${client.id}: registered with t#${token} as u#${member.id} / ${member.display_name}`); respond(null, member); io.to(serverId).emit('new-member', member); } ); } function bindChallengeVerificationEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity): void { identity.memberId = null; identity.serverId = null; identity.publicKey = null; identity.challenge = null; identity.verified = false; /* * Request a challenge for login * @param publicKeyBuff The public key encoded with der/spki * @param respond function(err, challenge) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.algo Signature algorithm to use * @param respond.type Signature response type to use * @param respond.challenge Challenge buffer to sign generated by crypto.randomBytes */ bindEvent( client, null, null, 'challenge', [ 'buffer', 'function' ], async (publicKeyBuff, respond) => { identity.verified = false; identity.memberId = null; identity.serverId = null; identity.publicKey = crypto.createPublicKey({ key: publicKeyBuff, format: 'der', type: 'spki' }); try { let memberInfo = await DB.getMemberInfo(identity.publicKey); identity.memberId = memberInfo.member_id; identity.serverId = memberInfo.server_id; } catch (e) { // unable to find a member with the specified public key throw new EventError('unauthorized public key', e); } LOG.debug(`c#${client.id}: challenging for u#${identity.memberId}`); if (connected.find(i => i.memberId == identity.memberId && i.verified)) { throw new EventError('member already connected'); } identity.challenge = crypto.randomBytes(64); respond(null, 'sha512', 'hex', identity.challenge); } ); /* * Verify a signature for login * @param signature The signed challenge using the client's private key * @param respond function(errStr, memberId) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.member Signed in member object * @param respond.server Signed in server object */ bindEvent( client, null, null, 'verify', [ 'string', 'function' ], async (signature, respond) => { if (connected.find(i => i.memberId == identity.memberId && i.verified)) { throw new EventError('member already connected'); } if (!identity.challenge) { throw new EventError('challenge not requested'); } if (!identity.publicKey) { throw new EventError('publicKey not provided') } if (!identity.serverId) { throw new EventError('serverId not targeted') } if (!identity.memberId) { throw new EventError('memberId not targeted') } let verify = crypto.createVerify('sha512'); verify.write(identity.challenge); verify.end(); let verified: boolean; try { verified = verify.verify(identity.publicKey, signature, 'hex'); } catch (e) { throw new EventError('unable to verify signature', e); } if (!verified) { throw new EventError('invalid signature'); } try { await DB.setMemberStatus(identity.serverId, identity.memberId, 'online'); let member = await DB.getMember(identity.serverId, identity.memberId); io.to(identity.serverId).emit('update-member', member); } catch (e) { LOG.warn('unable to set status for m#' + identity.memberId, e); // not killing here since this should not be a game ender. Most likely, the SQL server is bad though } identity.verified = true; client.join(identity.serverId); // join the socket.io server room LOG.info(`c#${client.id}: verified as s#${identity.serverId} u#${identity.memberId}`); respond(null, identity.memberId); } ); } function bindAdminEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity): void { /* * Sets the name of the server * @param name The new name of the server * @param respond function(errStr, serverMeta) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.serverMeta The update server metadata information */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_profile' ] }, 'set-name', [ 'string', 'function' ], async (name, respond) => { if (name.length == 0 || name.length > MAX_SERVER_NAME_LENGTH) throw new EventError('invalid server name'); if (!identity.serverId) throw new EventError('identity no serverId'); let newMeta = await DB.setName(identity.serverId, name); respond(null, newMeta); io.emit('update-server', newMeta); } ); /* * Sets the icon of the server * @param iconBuff The new icon buffer of the server * @param respond function(errStr, serverMeta) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.serverMeta The update server metadata information */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_profile' ] }, 'set-icon', [ 'buffer', 'function' ], async (iconBuff, respond) => { if (iconBuff.length == 0 || iconBuff.length > MAX_ICON_SIZE) throw new EventError('invalid server icon'); if (!identity.serverId) throw new EventError('identity no serverId'); let typeResult = await FileType.fromBuffer(iconBuff); if (!typeResult || !['image/png', 'image/jpeg', 'image/jpg'].includes(typeResult.mime)) { throw new EventError('detected invalid mime type'); } let iconResourceId = await DB.insertResource(identity.serverId, iconBuff); let newMeta = await DB.setIcon(identity.serverId, iconResourceId); respond(null, newMeta); io.emit('update-server', newMeta); } ); /* * Creates a text channel * @param name The name of the new channel * @param flavorText The flavor text of the new channel (can be null) * @para repsond function(errStr, channel) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.channel The created channel */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_channels' ] }, 'create-text-channel', [ 'string', 'string', 'function' ], async (name, flavorText, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); flavorText = flavorText || null; if (name.length == 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !(/^[A-Za-z0-9-]+$/.exec(name))) { throw new EventError('invalid channel name'); } if (flavorText != null && (flavorText.length == 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)) { throw new EventError('invalid flavor text'); } let newChannel = await DB.createChannel(identity.serverId, name, flavorText); respond(null, newChannel); io.to(identity.serverId).emit('new-channel', newChannel); } ); /* * Updates a channel * @param channelId The uuid of the channel to update * @param name The new name of the channel * @param flavorText The new flavor text of the channel * @param respond function(errStr, message) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.channel Updated channel */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_channels' ] }, 'update-channel', [ 'string', 'string', 'string', 'function' ], async (channelId, name, flavorText, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); flavorText = flavorText || null; if (name.length == 0 || name.length > MAX_CHANNEL_NAME_LENGTH || !(/^[A-Za-z0-9-]+$/.exec(name))) { throw new EventError('invalid name'); } if (flavorText != null && (flavorText.length == 0 || flavorText.length > MAX_CHANNEL_FLAVOR_TEXT_LENGTH)) { throw new EventError('invalid flavor text'); } let updatedChannel = await DB.updateChannel(identity.serverId, channelId, name, flavorText); respond(null, updatedChannel); io.to(identity.serverId).emit('update-channel', updatedChannel); } ); /* * Gets the list of tokens that have been or are available to use * @param respond function(errStr, message) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.tokens The list of outstanding tokens in form of [ { token, member_id, created, expires } ] */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_members' ] }, 'fetch-tokens', [ 'function' ], async (respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); LOG.debug(`u#${identity.memberId}: fetching tokens`); let tokens = await DB.getTokens(identity.serverId); respond(null, tokens); } ); /* * Revokes a token so that it can no longer be used * @param token The token to revoke * @param respond function(errStr) as a socket.io response function * @param respond.errStr Error string if an error, else null */ bindEvent( client, identity, { verified: true, privileges: [ 'modify_members' ] }, 'revoke-token', [ 'string', 'function' ], async (token, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!(await DB.isTokenReal(token))) { throw new EventError('fake token'); } if (!(await DB.isTokenForServer(token, identity.serverId))) { throw new EventError('fake token', undefined, 'tried to revoke token for a different server'); } if (await DB.isTokenTaken(token)) { throw new EventError('token already taken'); } LOG.debug(`u#${identity.memberId}: revoking t#${token}`); await DB.revokeToken(token); respond(null); } ); } function bindActionEvents(io: socketio.Server, client: socketio.Socket, identity: IIdentity) { /* * Send a message to a channel * @param channelId The uuid of the channel to send a message to * @param text The text of the message * @param respond function(errStr, message) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.message Sent message data object parsed from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'send-message', [ 'string', 'string', 'function' ], async (channelId, text, respond) => { if (text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long'); if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); let message = await DB.insertMessage(identity.serverId, channelId, identity.memberId, text); LOG.info(`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${message.text}`); respond(null, message); io.to(identity.serverId).emit('new-message', message); } ); /* * Send a message with a resource to a channel * @param channelId The uuid of the channel to send a message to * @param text The text of the message * @param resource The resource buffer of the message * @param resourceName The name of the resource * @param respond function(errStr, message) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.message Sent message data object parsed from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'send-message-with-resource', [ 'string', 'string?', 'buffer', 'string', 'function' ], async (channelId, text, resource, resourceName, respond) => { if (text && text.length > MAX_TEXT_MESSAGE_LENGTH) throw new EventError('message is too long'); if (resource.length > MAX_RESOURCE_SIZE) throw new EventError('resource is too large'); if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); resourceName = resourceName.trim(); resourceName = resourceName.replace(/[^A-Za-z0-9 \.]/g, '_'); // only alphanumerics for file names LOG.info(`u#${identity.memberId}: resource message with resource of size: ${resource.length} bytes`); // Try to get the dimensions of the resource if it is an image so that we can scale it down for the preview let fileType = (await FileType.fromBuffer(resource)) ?? { mime: null, ext: null }; let dimensions: { width: number | null, height: number | null } = { width: null, height: null }; switch (fileType.mime) { case 'image/png': case 'image/jpeg': case 'image/gif': let size = sizeOf(resource); dimensions.width = size.width ?? null; dimensions.height = size.height ?? null; break; } // Pre-scale the image (optimized for the electron discord client's max-dimensions of 400x300) let resourcePreview: Buffer | null = null; if (dimensions.width != null && dimensions.height != null) { let previewWidth = dimensions.width; let previewHeight = dimensions.height; if (previewWidth > 400) { let scale = 400 / previewWidth; previewWidth *= scale; previewHeight *= scale; } if (previewHeight > 300) { let scale = 300 / previewHeight; previewWidth *= scale; previewHeight *= scale; } // jpeg for image compression and trash visuals B) resourcePreview = await sharp(resource).resize(Math.floor(previewWidth), Math.floor(previewHeight)).jpeg().toBuffer(); } let message: any | null = null; try { await DB.queueTransaction(async () => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no serverId'); let resourcePreviewId: string | null = null; if (resourcePreview) { resourcePreviewId = await DB.insertResource(identity.serverId, resourcePreview); } let resourceId = await DB.insertResource(identity.serverId, resource); message = await DB.insertMessageWithResource(identity.serverId, channelId, identity.memberId, text, resourceId, resourceName, dimensions.width, dimensions.height, resourcePreviewId); }); } catch (e) { throw new EventError('unable to insert message with resource'); } LOG.info(`m#${message.id} ch#${message.channel_id} s@${formatDate(message.sent_dtg)} u#${message.member_id}: ${message.text ? message.text + ' / ' : ''}${message.resource_name}`); respond(null, message); io.to(identity.serverId).emit('new-message', message); } ); /* * Set the status for a verified member * @param status The status for the member to be set to * @param respond function(errStr, updatedMember) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.updatedMember The updated member object (note: an update-member event will be emitted) */ bindEvent( client, identity, { verified: true }, 'set-status', [ 'string', 'function' ], async (status, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: setting status to ${status}`); await DB.setMemberStatus(identity.serverId, identity.memberId, status); let updated = await DB.getMember(identity.serverId, identity.memberId); respond(null, updated); io.to(identity.serverId).emit('update-member', updated); } ); /* * Set the status for a verified member * @param displayName The new chosen display name * @param respond function(errStr, displayName) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.updatedMember The updated member object (note: an update-member event will be emitted) */ bindEvent( client, identity, { verified: true }, 'set-display-name', [ 'string', 'function' ], async (displayName, respond) => { if (displayName.length > MAX_DISPLAY_NAME_LENGTH || displayName.length == 0) throw new EventError('invalid display name'); if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: setting display name to ${displayName}`); await DB.setMemberDisplayName(identity.serverId, identity.memberId, displayName); let updated = await DB.getMember(identity.serverId, identity.memberId); respond(null, updated); io.to(identity.serverId).emit('update-member', updated); } ); /* * Set the status for a verified member * @param avatarBuff The new chosen avatar buffer * @param respond function(errStr, displayName) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.updatedMember The updated member object (note: an update-member event will be emitted) */ bindEvent( client, identity, { verified: true }, 'set-avatar', [ 'buffer', 'function' ], async (avatarBuff, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); if (avatarBuff.length > MAX_AVATAR_SIZE) { LOG.warn(`c#${client.id}: avatar too large`); respond('buffer too large'); return; } let typeResult = (await FileType.fromBuffer(avatarBuff)) ?? { mime: null, ext: null }; if ((['image/png', 'image/jpeg', 'image/jpg'] as (string | null)[]).indexOf(typeResult.mime) == -1) { throw new EventError('invalid avatar buffer'); } LOG.info(`u#${identity.memberId}: uploaded new avatar`); let resourceId = await DB.insertResource(identity.serverId, avatarBuff); await DB.setMemberAvatarResourceId(identity.serverId, identity.memberId, resourceId); let updated = await DB.getMember(identity.serverId, identity.memberId); respond(null, updated); io.to(identity.serverId).emit('update-member', updated); } ); } function bindFetchEvents(client: socketio.Socket, identity: IIdentity): void { // TODO: consider changing all or some of these to HTTP requests /* * Fetch the server information (name, icon, icon_hash, etc.) * @param respond function(errStr, server) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.server The server information */ bindEvent( client, identity, { verified: true }, 'fetch-server', [ 'function' ], async (respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching server`); let server = await DB.getServer(identity.serverId); respond(null, server); } ); /* * Fetch a list of channels * @param respond function(errStr, channels) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.channels The list of channels for this server */ bindEvent( client, identity, { verified: true }, 'fetch-channels', [ 'function' ], async (respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching channels`); let channels = await DB.getChannels(identity.serverId); respond(null, channels); } ); /* * Fetch the most recent messages for a specific channel * @param channelId The channel uuid of the channel * @param number The maximum number of messages to get * @param respond function(errStr, messages) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.messages List of message data objects in the channel from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'fetch-messages-recent', [ 'string', 'number', 'function' ], async (channelId, number, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching recent messages for ch#${channelId} number: ${number}`); let messages = await DB.getMessagesRecent(identity.serverId, channelId, number); respond(null, messages); } ); /* * client.on('fetch-messages-before', (channelId, messageId, number, respond(errStr, messages))) * Fetch messages coming before the specified message * @param channelId The channel uuid of the channel * @param messageId The id of the base message (will not be included in results) * @param number The maximum number of messages to get * @param respond function(errStr, messages) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.messages List of message data objects in the channel from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'fetch-messages-before', [ 'string', 'string', 'number', 'function' ], async (channelId, messageId, number, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching messages before ch#${channelId} m#${messageId} number: ${number}`); let messages = await DB.getMessagesBefore(identity.serverId, channelId, messageId, number); respond(null, messages); } ); /* * Fetch messages coming after the specified message * @param channelId The channel uuid of the channel * @param messageId The id of the base message (will not be included in results) * @param number The maximum number of messages to get * @param respond function(errStr, messages) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.messages List of message data objects in the channel from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'fetch-messages-after', [ 'string', 'string', 'number', 'function' ], async (channelId, messageId, number, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching messages after ch#${channelId} m#${messageId} number: ${number}`); let messages = await DB.getMessagesAfter(identity.serverId, channelId, messageId, number); respond(null, messages); } ); /* * @param resourceId The id of the resource to fetch * @param respond function(errStr, resource) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.resource Resource object { id, server_id, hash, data } */ bindEvent( client, identity, { verified: true }, 'fetch-resource', [ 'string', 'function' ], async (resourceId, respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching r#${resourceId}`); let resource = await DB.getResource(identity.serverId, resourceId); respond(null, resource); } ); /* * Fetch members in the server * @param respond function(errStr, members) as a socket.io response function * @param respond.errStr Error string if an error, else null * @param respond.members List of member data objects in the server from PostgreSQL */ bindEvent( client, identity, { verified: true }, 'fetch-members', [ 'function' ], async (respond) => { if (!identity.serverId) throw new EventError('identity no serverId'); if (!identity.memberId) throw new EventError('identity no memberId'); LOG.info(`u#${identity.memberId}: fetching members`); let members = await DB.getMembers(identity.serverId); respond(null, members); } ); } export function bindSocketEvents(io: socketio.Server): void { io.on('connection', client => { LOG.info(`c#${client.id}: connected`); let identity: IIdentity = { serverId: null, memberId: null, verified: false, publicKey: null, challenge: null }; connected.push(identity); //bindIdentificationEvents(io, keypair, client); bindRegistrationEvents(io, client); bindChallengeVerificationEvents(io, client, identity); bindAdminEvents(io, client, identity); bindActionEvents(io, client, identity); bindFetchEvents(client, identity); client.on('disconnect', () => { connected.splice(connected.findIndex(i => i.memberId == identity.memberId), 1); if (identity.verified) { LOG.info(`c#${client.id}: disconnected (was u#${identity.memberId})`); (async () => { if (!identity.serverId || !identity.memberId) return; try { await DB.setMemberStatus(identity.serverId, identity.memberId, 'offline'); } catch (e) { LOG.error('Error updating member status on disconnect', e); } })(); } else { LOG.info(`c#${client.id}: disconnected (was unverified)`); } }); }); }