853 lines
35 KiB
TypeScript
853 lines
35 KiB
TypeScript
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<void>)
|
|
) {
|
|
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)`);
|
|
}
|
|
});
|
|
});
|
|
}
|