reflectionircd/src/Client.ts
2022-03-29 16:22:05 -04:00

568 lines
No EOL
22 KiB
TypeScript

import { Axios } from 'axios';
import { randomUUID } from 'crypto';
import { Socket } from 'net';
import { Channel } from './Channel.js';
import { MatrixUser } from './MatrixUser.js';
import { IRCMessage, parseIRCMessage } from './Message.js';
import { Server } from './Server.js';
export class Client {
capVersion: string
enabledCaps: Map<string, string>
allCaps: Map<string, string>
user: MatrixUser
isRegistered: boolean
apiCall: Axios;
constructor(private socket: Socket, public server: Server) {
this.capVersion = '301';
this.enabledCaps = new Map();
this.allCaps = new Map([
["account-tag", ""],
["batch", ""],
["draft/channel-rename", ""],
["echo-message", ""],
["extended-join", ""],
["invite-notify", ""],
["message-tags", ""],
["sasl", "PLAIN"],
["server-time", ""],
]);
this.user = this.server.ourMatrixUser;
this.isRegistered = false;
this.apiCall = this.server.apiCall;
this.server.doLog("New client connected");
this.socket.on('data', (data) => this.receiveData(data));
//this.socket.on('close', (e) => {if (this.user) this.user.handleClientClose(this, e)});
}
checkIfRegistered() {
return this.isRegistered;
}
receiveData(data: Buffer|String) {
const dataArray = data.toString().split('\r\n');
dataArray.forEach(m => {
const trimmedMsg = m.replace('\r', '').replace('\n', '');
if (trimmedMsg !== '')
this.routeMessage(trimmedMsg);
});
}
getMatrixUserFromNick(targetNick: string) {
const target = this.server.nickToMatrixUser.get(targetNick);
if (!target) {
this.sendMessage(this.server.name, "401", [this.user.nick, targetNick, "No such nick"]);
return false;
}
return target;
}
getChannel(channel: string) {
const targetChannel = this.server.channels.get(channel);
if (!targetChannel) {
this.sendMessage(this.server.name, "403", [this.user.nick, channel, "No such channel"]);
return false;
}
return targetChannel;
}
checkIfInChannel(channel: Channel) {
if (!this.server.channels.get(channel.name)) {
this.sendMessage(this.server.name, "442", [this.user.nick, "You're not on that channel"]);
return false;
}
return true;
}
checkMinParams(message: IRCMessage, neededNumber: number) {
if (message.params.length < neededNumber) {
this.sendMessage(this.server.name, "461", [this.user.nick, message.command, "Not enough parameters"]);
return false;
}
return true;
}
getCapString(capVersion: string) {
let capArray = [];
for (const [key, value] of this.allCaps.entries()) {
if (capVersion === '301' || value.length === 0) {
capArray.push(key);
}
else {
capArray.push(`${key}=${value}`);
}
}
return capArray.join(' ');
}
routeMessage(data: string) {
const message = parseIRCMessage(data);
switch (message.command.toUpperCase()) {
case 'AUTHENTICATE':
this.doAUTHENTICATE(message);
break;
case 'CAP':
this.doCAP(message);
break;
case 'DELETEMSG':
this.doDELETEMSG(message);
break;
case 'INVITE':
this.doINVITE(message);
break;
case 'KICK':
this.doKICK(message);
break;
case 'MODE':
this.doMODE(message);
break;
case 'NAMES':
this.doNAMES(message);
break;
case 'NOTICE':
this.doMSG(message);
break;
case 'PART':
this.doPART(message);
break;
case 'PING':
this.sendMessage(this.server.name, "PONG", message.params, message.tags);
break;
case 'PRIVMSG':
this.doMSG(message);
break;
case 'TAGMSG':
this.doTAGMSG(message);
break;
case 'TOPIC':
this.doTOPIC(message);
break;
case 'WHO':
this.doWHO(message);
break;
}
}
doAUTHENTICATE(message: IRCMessage) {
if (message.params[0] === "PLAIN") {
this.sendMessage("", "AUTHENTICATE", ["+"]);
}
else {
const authArray = Buffer.from(message.params[0], 'base64').toString('utf-8').split('\0');
if (!authArray || authArray.length !== 3) {
this.sendMessage(this.server.name, '904', ['*', "SASL Authentication failed"])
this.closeConnectionWithError('Invalid authentication')
}
if (authArray[2] === this.server.config.SASLPassword) {
this.sendMessage(this.server.name, '900', [this.user.nick, this.server.getMask(), this.user.accountName, `You are now logged in as ${this.user.nick}`]);
this.sendMessage(this.server.name, '903', [this.user.nick, "SASL authentication successful"]);
this.isRegistered = true;
}
}
}
doCAP(message: IRCMessage) {
switch (message.params[0]) {
case 'LS': {
if (message.params.length === 2) {
this.capVersion = message.params[1];
}
this.sendMessage(this.server.name, "CAP", ["*", "LS", this.getCapString(this.capVersion)]);
break;
}
case 'LIST': {
this.sendMessage(this.server.name, "CAP", ["*", "LIST", this.getCapString(this.capVersion)]);
break;
}
case 'REQ': {
const capsToChange = (message.params[1].indexOf(' ') === -1) ? [message.params[1]] : message.params[1].split(' ');
const capsEnabled: string[] = [];
capsToChange.forEach(cap => {
if (this.allCaps.has(cap)) {
this.enabledCaps.set(cap, '');
capsEnabled.push(cap);
}
});
this.sendMessage(this.server.name, "CAP", ["*", "ACK", capsEnabled.join(' ')]);
break;
}
case 'END': {
if (this.isRegistered) {
this.doRegistration(message);
}
else {
this.closeConnectionWithError("You must use SASL to connect to this server");
}
}
}
}
doDELETEMSG(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
const eventId = message.tags.get("draft/delete-message");
if (!this.user || !targetChannel || !eventId) return;
if (!this.checkIfInChannel(targetChannel)) return;
const data = {
"reason": (message.params.length === 2) ? message.params[1] : ""
}
const newTxnid = randomUUID();
this.apiCall.put(`/rooms/${targetChannel.roomId}/redact/${eventId}/${newTxnid}`, data).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
})
}
doINVITE(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 2))
return;
const targetUser = this.getMatrixUserFromNick(message.params[0]);
const targetChannel = this.getChannel(message.params[1]);
if (!this.user || !targetUser || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
if (targetChannel.matrixUsers.has(targetUser.nick)) {
this.sendMessage(this.server.name, "443", [this.user.nick, targetUser.nick, "is already on channel"]);
return;
}
const reason = (message.params.length === 3) ? message.params[2] : "";
const data = {
"reason": reason,
"user_id": targetUser.mxid
}
this.apiCall.post(`/rooms/${targetChannel.roomId}/invite`, data).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
})
}
doKICK(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 2))
return;
const targetChannel = this.getChannel(message.params[0]);
const targetUser = this.getMatrixUserFromNick(message.params[1]);
if (!this.user || !targetUser || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
if (!targetChannel.matrixUsers.has(targetUser.nick)) {
this.sendMessage(this.server.name, "441", [this.user.nick, targetUser.nick, targetChannel.name, "They aren't on that channel"]);
return;
}
const reason = (message.params.length === 3) ? message.params[2] : "";
const data = {
"reason": reason,
"user_id": targetUser.mxid
}
this.apiCall.post(`/rooms/${targetChannel.roomId}/kick`, data).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
})
}
doMODE(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1) || !this.user)
return;
const targetChannel = this.server.channels.get(message.params[0]);
if (!targetChannel) {
if (message.params[0] !== this.user.nick) {
this.sendMessage(this.server.name, "502", [this.user.nick, "Can't view mode for other users"]);
return;
}
this.sendMessage(this.server.name, "221", [this.user.nick, "+i"]);
return;
}
if (!this.checkIfInChannel(targetChannel)) return;
if (message.params.length === 1) {
const chanModes = [...targetChannel.channelModes.keys()].sort().join('');
this.sendMessage(this.server.name, "324", [this.user.nick, targetChannel.name, `+${chanModes}`]);
return;
}
}
doMSG(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 2))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
let msgtype = 'm.text';
let msgbody = message.params[1];
if (message.command === 'NOTICE')
msgtype = 'm.notice';
else if (message.params[1].startsWith('\x01')) {
msgtype = 'm.emote';
msgbody = msgbody.replace(/\x01(ACTION\s)?/gi, '');
}
const highlightFilteredMsg = msgbody.split(" ").map(w => {
if (!w.startsWith('@')) return w;
const endingCharMatch = w.match(/[,:]$/);
const endingChar = (endingCharMatch) ? endingCharMatch[0] : "";
const endingCharIndex = (endingCharMatch) ? endingCharMatch.index : 0;
const nickToSearch = (endingCharIndex === 0) ? w.substring(1) : w.substring(1, endingCharIndex);
const maybeHighlight = targetChannel.matrixUsers.get(nickToSearch);
return (maybeHighlight) ? `${maybeHighlight.mxid}${endingChar}` : w;
})
const content = {
"body": highlightFilteredMsg.join(" "),
"msgtype": msgtype,
"m.relates_to": {}
}
if (message.tags.has("+draft/reply")) {
content["m.relates_to"] = {
"m.in_reply_to": {
"event_id": message.tags.get("+draft/reply")
}
}
}
const newTxnid = randomUUID();
this.server.txnIdStore.set(newTxnid, this);
this.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.room.message/${newTxnid}`, content).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
});
}
sendNAMES(targetChannel: Channel) {
if (!this.user) return;
let namesList: string[] = [];
for (const matrixUser of targetChannel.matrixUsers.values()) {
const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick);
namesList.push(`${opStatus}${matrixUser.nick}`);
}
let singleNamesList: string[] = []
namesList.forEach((singleName, index) => {
if (index === 0 || index % 20 !== 0) {
singleNamesList.push(singleName);
}
else {
this.sendMessage(this.server.name, "353", [this.user.nick, "=", targetChannel.name, `${singleNamesList.join(' ')}`]);
singleNamesList = [];
}
})
if (singleNamesList.length !== 0) {
this.sendMessage(this.server.name, "353", [this.user.nick, "=", targetChannel.name, `${singleNamesList.join(' ')}`]);
}
this.sendMessage(this.server.name, "366", [this.user.nick, targetChannel.name, "End of /NAMES list"]);
}
doNAMES(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
this.sendNAMES(targetChannel);
}
doPART(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
const reason = (message.params.length === 2) ? message.params[1] : "";
this.apiCall.post(`/rooms/${targetChannel.roomId}/leave`, {"reason": reason}).then(response => {
if (response.status === 200) {
//@ts-ignore
this.user.getClients().forEach(c => {
//@ts-ignore
c.sendMessage(this.user.getMask(), "PART", [targetChannel.name, reason]);
})
//@ts-ignore
this.user.channels.delete(targetChannel.name);
//@ts-ignore
this.user.roomIdToChannel.delete(targetChannel.roomId);
}
else {
//@ts-ignore
this.sendMessage(this.server.name, "NOTICE", [this.user.nick, JSON.stringify(response.data)]);
}
}).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
})
}
doTAGMSG(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
if (message.tags.has("+draft/react") && message.tags.has("+draft/reply")) {
const content = {
"m.relates_to": {
"event_id": message.tags.get("+draft/reply"),
"key": message.tags.get("+draft/react"),
"rel_type": "m.annotation"
}
}
const newTxnid = randomUUID();
this.server.txnIdStore.set(newTxnid, this);
this.apiCall.put(`/rooms/${targetChannel.roomId}/send/m.reaction/${newTxnid}`, content).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
});
}
}
sendTOPIC(targetChannel: Channel) {
if (!this.user) return;
const topicText = targetChannel.topic.get('text') || '';
const topicSetter = targetChannel.topic.get('setter') || 'matrix';
const topicTimestamp = targetChannel.topic.get('timestamp') || '0';
if (topicText === '') {
this.sendMessage(this.server.name, '331', [this.user.nick, targetChannel.name, 'No topic is set']);
return;
}
this.sendMessage(this.server.name, '332', [this.user.nick, targetChannel.name, topicText]);
this.sendMessage(this.server.name, '333', [this.user.nick, targetChannel.name, topicSetter, topicTimestamp]);
}
doTOPIC(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
if (message.params.length === 1) {
this.sendTOPIC(targetChannel);
return;
}
const topic = message.params[1];
this.apiCall.put(`/rooms/${targetChannel.roomId}/state/m.room.topic`, {"topic": topic}).catch(function (error) {
if (error.response) {
console.log(error.response.data);
} else if (error.request) {
console.log(error.request);
} else {
console.log('Error', error.message);
console.log(error.config);
}
})
}
doWHO(message: IRCMessage) {
if (!this.checkIfRegistered() || !this.checkMinParams(message, 1))
return;
const targetChannel = this.getChannel(message.params[0]);
if (!this.user || !targetChannel) return;
if (!this.checkIfInChannel(targetChannel)) return;
for (const matrixUser of targetChannel.matrixUsers.values()) {
const opStatus = targetChannel.getNickPowerLevelMapping(matrixUser.nick);
const userParams = [
this.user.nick,
targetChannel.name,
matrixUser.ident,
matrixUser.hostname,
this.server.name,
matrixUser.nick,
`H${opStatus}`,
`0 ${matrixUser.realname}`
]
this.sendMessage(this.server.name, '352', userParams);
}
this.sendMessage(this.server.name, '315', [this.user.nick, targetChannel.name, "End of /WHO"]);
}
doRegistration(message: IRCMessage) {
if (this.user === null) {
this.closeConnectionWithError("You must use SASL to connect to this server");
return;
}
this.sendMessage(this.server.name, '001', [this.user.nick, `Welcome to the ${this.server.name} network, ${this.user.nick}`])
this.sendMessage(this.server.name, '002', [this.user.nick, `Your host is ${this.server.name}, running version 0.1.0`]);
this.sendMessage(this.server.name, '003', [this.user.nick, `This server was created yesterday`]);
this.sendMessage(this.server.name, '004', [this.user.nick, this.server.name, '0.1.0', 'i', 'hnouv']);
const iSupportArray = [
'CASEMAPPING=ascii',
'CHANMODES=,,,nu',
'CHANTYPES=#&!',
'MAXTARGETS=1',
'PREFIX=(ohv)@%+',
]
if (this.enabledCaps.has('draft/chathistory')) {
iSupportArray.push('CHATHISTORY=50');
}
this.sendMessage(this.server.name, '005', [this.user.nick, ...iSupportArray, 'are supported by this server']);
this.sendMessage(this.server.name, '375', [this.user.nick, "- Start of MOTD"]);
this.sendMessage(this.server.name, '372', [this.user.nick, "It's an MOTD"]);
this.sendMessage(this.server.name, '376', [this.user.nick, "- End of MOTD"]);
this.sendMessage(this.user.nick, 'MODE', [this.user.nick, '+i']);
this.server.addClient(this);
}
sendMessage(prefix: string, command: string, params: string[], tags: Map<string, string> = new Map()) {
const capTagMapping = new Map([
['account', 'account-tag'],
['label', 'labeled-response'],
['msgid', 'message-tags'],
['draft/delete-message', 'draft/edit-message'],
['draft/edit-message', 'draft/edit-message'],
['time', 'server-time'],
])
const ourTags: Map<string, string> = new Map();
if (this.enabledCaps.has('server-time') && !tags.has('time'))
ourTags.set('time', new Date().toISOString());
tags.forEach((v, k) => {
if (k.startsWith('+')) {
if (this.enabledCaps.has('message-tags')) {
ourTags.set(k, v);
}
}
else {
const capToCheck = capTagMapping.get(k) || '';
if (this.enabledCaps.has(capToCheck)) {
ourTags.set(k, v);
}
}
})
const newMsg = new IRCMessage(ourTags, prefix, command, params);
const msgToSend = newMsg.toString();
//console.log(`SENT: ${msgToSend}`);
this.socket.write(`${msgToSend}\r\n`);
}
closeConnectionWithError(message: string) {
this.sendMessage(this.server.name, 'ERROR', [message], new Map());
this.socket.destroy();
}
}