diff --git a/.gitignore b/.gitignore index d49756d..48936ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /node_modules/ /build/ +/client.config.toml diff --git a/client.config.example.toml b/client.config.example.toml new file mode 100644 index 0000000..99398af --- /dev/null +++ b/client.config.example.toml @@ -0,0 +1,9 @@ +[server] + port=8080 + +[client] + username="teemant" + realname="A Teemant User" + default_quit_msg="Teemant IRC" + default_part_msg="Bye!" + secure_by_default=false diff --git a/package.json b/package.json index 15d685a..b12259c 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ "license": "MIT", "dependencies": { "express": "^4.14.0", - "jade": "^1.11.0", "morgan": "^1.7.0", - "socketio": "^1.0.0" + "socketio": "^1.0.0", + "toml": "^2.3.0" }, "repository": { "type": "git", diff --git a/public/css/main.css b/public/css/main.css index 8edb0cf..41ec308 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -376,7 +376,11 @@ body { color: #03a9f4; font-weight: bold; } -.message.m_nick .content { +.message.m_motd .content { + font-family: monospace; +} +.message.m_nick .content, +.message.m_notice .content { color: #ff9800; font-weight: bold; } diff --git a/public/image/settings.svg b/public/image/settings.svg index 07405f0..bbe7c2b 100644 --- a/public/image/settings.svg +++ b/public/image/settings.svg @@ -27,4 +27,5 @@ + diff --git a/public/js/main.js b/public/js/main.js index a5fbae3..ff87060 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -4,6 +4,7 @@ window.irc = { timestamps: true, timestampFormat: "HH:mm:ss", serverData: {}, + serverChatQueue: {}, chatType: "simple" }; @@ -125,6 +126,20 @@ Date.prototype.format = function (format, utc){ return format; }; +function linkify(text) { + // see http://daringfireball.net/2010/07/improved_regex_for_matching_urls + let re = /\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))/gi; + let parsed = text.replace(re, function(url) { + // turn into a link + let href = url; + if (url.indexOf('http') !== 0) { + href = 'http://' + url; + } + return '' + url + ''; + }); + return parsed; +} + function removeClass(element, cl) { let classList = element.className.split(" "); remove_str(classList, cl); @@ -154,6 +169,8 @@ let composer = { if(irc.timestamps) element.innerHTML += ""+time.format(irc.timestampFormat)+" "; + message = linkify(message); + switch(type) { case "action": element.innerHTML += "* "+sender+" "; @@ -250,6 +267,7 @@ class Nicklist { } nickAddObject(obj) { + if(this.getNickIndex(obj.nickname) != null) return; this.nicks.push(obj); } @@ -424,7 +442,7 @@ class Buffer { if(this.topic != null && this.topic != "") { addClass(clientdom.chat, 'vtopic'); - clientdom.topicbar.innerHTML = this.topic; + clientdom.topicbar.innerHTML = linkify(this.topic); } this.renderMessages(); @@ -455,7 +473,7 @@ class Buffer { topicChange(topic) { if(this.active) { - clientdom.topicbar.innerHTML = topic; + clientdom.topicbar.innerHTML = linkify(topic); if(this.topic == null) addClass(clientdom.chat, "vtopic"); @@ -463,8 +481,8 @@ class Buffer { this.topic = topic; } - addMessage(message, sender, type) { - let mesg = {message: message, sender: sender, type: type, time: new Date()} + addMessage(message, sender, type, time) { + let mesg = {message: message, sender: sender, type: type, time: time || new Date()} this.messages.push(mesg); if(this.active) @@ -689,6 +707,15 @@ class IRCChatWindow { this.buffers.push(newServer); this.render(newServer); this.firstServer = false; + + if(irc.serverChatQueue[serverinfo.address]) { + for(let a in irc.serverChatQueue[serverinfo.address]) { + let mesg = irc.serverChatQueue[serverinfo.address][a]; + newServer.addMessage(mesg.message, mesg.from, mesg.type, mesg.time); + } + delete irc.serverChatQueue[serverinfo.address]; + } + } createBuffer(server, name, type, autoswitch) { @@ -918,16 +945,24 @@ window.onload = function() { else if(data['topic']) irc.chat.topicChange(data.channel, data.server, data.topic, null); else if(data['set_by']) - irc.chat.messageBuffer(data.channel, data.server, {message: "Topic set by "+data.set_by+" on "+new Date(data.time), type: "topic", from: null}); + irc.chat.messageBuffer(data.channel, data.server, {message: "Topic set by "+data.set_by+" on "+new Date(data.time*1000), type: "topic", from: null}); break; case "nick_change": irc.chat.nickChange(data.server, data.nick, data.newNick); break; case "server_message": - irc.chat.messageBuffer(data.server, data.server, {message: data.message, type: data.messageType, from: null}); + if(irc.chat.getBuffersByServer(data.server).length == 0) { + if(!irc.serverChatQueue[data.server]) { + irc.serverChatQueue[data.server] = []; + } else { + irc.serverChatQueue[data.server].push({type: data.messageType, message: data.message, from: data.from || null, time: new Date()}); + } + } else { + irc.chat.messageBuffer(data.server, data.server, {message: data.message, type: data.messageType, from: data.from || null}); + } break; case "connect_message": - irc.auther.authMessage(data.data, data.error); + irc.auther.authMessage(data.message, data.error); break; } }); diff --git a/public/main.styl b/public/main.styl index e4f3442..607d462 100644 --- a/public/main.styl +++ b/public/main.styl @@ -322,7 +322,9 @@ body &.m_topic .content color: #03A9F4; font-weight: bold; - &.m_nick .content + &.m_motd .content + font-family: monospace; + &.m_nick .content, &.m_notice .content color: #FF9800; font-weight: bold; &.m_action .actionee diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..10656d9 --- /dev/null +++ b/server/config.js @@ -0,0 +1,14 @@ +let fs = require('fs'); +let toml = require('toml'); +let filename = __dirname+'/../client.config.toml'; + +let config; + +try { + config = toml.parse(fs.readFileSync(filename)); +} catch (e) { + throw 'config.toml parse error: ' + e; + console.error(e.stack); +} + +module.exports = config; diff --git a/server/irc.js b/server/irc.js new file mode 100644 index 0000000..9593eb3 --- /dev/null +++ b/server/irc.js @@ -0,0 +1,284 @@ +let EventEmitter = require('events').EventEmitter; +let net = require('net'); +let configuration = require(__dirname+"/config"); +let parse = require(__dirname+"/parser"); + +let defaultConfig = { + nickname: "teemant", + username: configuration.client.username, + realname: configuration.client.realname, + server: "localhost", + port: 6667, + autojoin: [], + secure: configuration.client.secure_by_default, + password: "", + address: "0.0.0.0" +} + +if (!String.prototype.format) { + String.prototype.format = function() { + var args = arguments; + return this.replace(/{(\d+)}/g, function(match, number) { + return typeof args[number] != undefined ? args[number] : match; + }); + }; +} + +class ConfigPatcher { + constructor(provided, defaults) { + this.result = defaults; + this.patches = provided; + } + + patch() { + for(let a in this.patches) { + this.result[a] = this.patches[a]; + } + return this.result; + } +} + +class IRCConnectionHandler { + constructor(connection) { + this.conn = connection; + } + + handleServerLine(line) { + console.log(line); + if(this.conn.queue["supportsmsg"] && line.command != "005") { + + delete this.conn.queue["supportsmsg"]; + + if(this.conn.config.autojoin.length > 0) + for(let t in this.conn.config.autojoin) + this.conn.socket.write('JOIN {0}\r\n'.format(this.conn.config.autojoin[t])); + + this.conn.emit('authenticated', {}); + } + + let serverName = this.conn.config.server; + let realServerName = this.conn.data.actualServer; + + switch(line.command) { + case "error": + this.conn.emit("error", {type: "irc_error", raw: line.raw}); + break; + case "001": + this.conn.data.actualServer = line.user.host; + break + case "005": + if(!this.conn.queue["supportsmsg"]) + this.conn.queue["supportsmsg"] = true; + + let argv = line.arguments.slice(1); + for(let a in argv) { + let t = argv[a]; + if(t.indexOf('=') != -1) { + t = t.split('='); + if(t[0] === 'PREFIX') { + let d = t[1].match(/\((\w+)\)(.*)/); + let r = d[1].split(''); + let aa = d[2].split(''); + for(let b in r) + this.conn.data.supportedModes[r[b]] = aa[b]; + } else if(t[0] === 'NETWORK') { + this.conn.data.network = t[1]; + } + + this.conn.data.serverSupports[t[0]] = t[1]; + } else { + this.conn.data.serverSupports[t] = true; + } + } + break; + case "JOIN": + this.conn.emit('pass_to_client', {type: "event_join_channel", user: line.user, channel: line.trailing, server: serverName }); + break; + case "PART": + this.conn.emit('pass_to_client', {type: "event_part_channel", user: line.user, channel: line.arguments[0], reason: line.trailing, server: serverName }); + break; + case "QUIT": + this.conn.emit('pass_to_client', {type: "event_quit", user: line.user, reason: line.trailing, server: serverName }); + break; + case "353": + if(!this.conn.queue["names"]) + this.conn.queue['names'] = {}; + + let splittrail = line.trailing.split(' '); + for(let a in splittrail) { + let nick = splittrail[a]; + if(nick.trim() == "") continue; + if(this.conn.queue["names"][line.arguments[2]]) + this.conn.queue["names"][line.arguments[2]].push(nick); + else + this.conn.queue["names"][line.arguments[2]] = [nick]; + } + + break; + case "366": + if(!this.conn.queue["names"]) break; + if(this.conn.queue["names"][line.arguments[1]]) { + this.conn.emit('pass_to_client', {type: "channel_nicks", channel: line.arguments[1], nicks: this.conn.queue["names"][line.arguments[1]], server: serverName}); + delete this.conn.queue["names"][line.arguments[1]]; + } + + if(Object.keys(this.conn.queue["names"]).length == 0) + delete this.conn.queue["names"]; + break; + case "PRIVMSG": + let type = "privmsg"; + + if(line.trailing.indexOf('\x01ACTION') == 0) { + line.trailing = line.trailing.substring(8); + line.trailing.substring(0, line.trailing.length-1); + type = "action"; + } + + if(line.user.nickname != "") + this.conn.emit('pass_to_client', {type: "message", messageType: type, to: line.arguments[0], + user: line.user, message: line.trailing, server: serverName}); + else + this.conn.emit('pass_to_client', {type: "server_message", messageType: type, message: line.trailing, server: serverName, from: realServerName}); + break; + case "NOTICE": + if(line.user.nickname != "") + this.conn.emit('pass_to_client', {type: "message", messageType: "notice", to: line.arguments[0], + user: line.user, message: line.trailing, server: serverName}); + else + this.conn.emit('pass_to_client', {type: "server_message", messageType: "notice", message: line.trailing, server: serverName, from: realServerName}); + break; + case "NICK": + this.conn.emit('pass_to_client', {type: "nick_change", nick: line.user.nickname, newNick: line.arguments[0], server: serverName}); + break; + case "KICK": + this.conn.emit('pass_to_client', {type: "event_kick_channel", user: line.user, channel: line.arguments[0], kickee: line.arguments[1], server: serverName}); + break; + case "TOPIC": + this.conn.emit('pass_to_client', {type: "channel_topic", channel: line.arguments[0], set_by: line.user.nickname, topic: line.trailing, server: serverName}); + break; + case "332": + this.conn.emit('pass_to_client', {type: "channel_topic", channel: line.arguments[1], topic: line.trailing, server: serverName}); + break; + case "333": + this.conn.emit('pass_to_client', {type: "channel_topic", channel: line.arguments[1], set_by: line.arguments[2], time: line.arguments[3], server: serverName}); + break; + case "375": + case "372": + case "376": + this.conn.emit('pass_to_client', {type: "server_message", messageType: "motd", message: line.trailing, server: serverName, from: realServerName}); + break; + case "251": + case "255": + this.conn.emit('pass_to_client', {type: "server_message", messageType: "regular", message: line.trailing, server: serverName, from: realServerName}); + break; + case "252": + case "254": + case "042": + this.conn.emit('pass_to_client', {type: "server_message", messageType: "regular", message: line.arguments[1] +" "+ line.trailing, server: serverName, from: realServerName}); + break; + } + } +} + +class IRCConnection extends EventEmitter { + constructor(providedInfo) { + super(); + let config_u = new ConfigPatcher(providedInfo, defaultConfig); + + this.config = config_u.patch(); + this.socket = null; + this.connected = false; + this.authenticated = false; + + this.handler = new IRCConnectionHandler(this); + + this.data = { + serverSupports: {}, + network: this.config.server, + actualServer: this.config.server, + supportedModes: {} + }; + this.queue = {}; + } + + on(...args) { + return super.on(...args) + } + + emit(...args) { + return super.emit(...args); + } + + connect() { + this.socket = net.createConnection(this.config.port, this.config.server, () => { + this.connected = true; + this.authenticate(); + }); + + this.socket.setEncoding('utf8'); + this.socket.setTimeout(3000); + + this.socket.on('error', (data) => { + this.emit('error', {type: "sock_error", message: "A socket error occured.", raw: data}); + }); + + this.socket.on('lookup', (err, address, family, host) => { + if(err) { + this.emit('error', {type: "resolve_error", message: "Failed to resolve host."}); + } else { + this.emit('lookup', {address: address, family: address, host: host}); + this.config.address = address; + } + }); + + let buffer = ""; + this.socket.on('data', (chunk) => { + buffer += chunk; + let data = buffer.split('\r\n'); + buffer = data.pop(); + data.forEach((line) => { + if(line.indexOf('PING') === 0) { + this.socket.write('PONG'+line.substring(4)+'\r\n'); + return + } + this.emit('raw', line); + let parsed = parse(line); + this.emit('line', parsed); + this.handler.handleServerLine(parsed); + }); + }); + + this.socket.on('close', (data) => { + if(this.queue['close']) + this.emit('closed', {type: "sock_closed_success", raw: data, message: "Connection closed."}); + else + this.emit('closed', {type: "sock_closed", raw: data, message: "Connection closed."}); + + this.connected = false; + this.authenticated = false; + }) + } + + authenticate() { + if (this.config.password) + this.socket.write('PASS {0}\r\n'.format(this.config.password)); + + // TODO: WebIRC + this.socket.write('USER {0} 8 * :{1}\r\n'.format(this.config.username, this.config.realname)); + this.socket.write('NICK {0}\r\n'.format(this.config.nickname)); + } + + disconnect(message) { + if(!this.connected) { + this.emit('error', {type: "sock_closed", message: "Connection already closed."}); + return; + } + + this.queue['close'] = true; + this.socket.write('QUIT :{0}\r\n'.format(message != null ? message : configuration.client.default_quit_msg)); + } + + +} + +module.exports = IRCConnection; diff --git a/server/parser.js b/server/parser.js new file mode 100644 index 0000000..dff41fc --- /dev/null +++ b/server/parser.js @@ -0,0 +1,44 @@ + +// :nickname!username@hostname command arg ume nts :trailing +// or +// :hostname command arg ume nts :trailing + +module.exports = function(rawline) { + let final = { + user: { + nickname: "", + username: "", + hostname: "" + }, + command: "error", + arguments: [], + trailing: "", + raw: rawline + } + + let pass1 = (rawline.indexOf(':') == 0 ? rawline.substring(1).split(" ") : rawline.split(" ")); + if (pass1[0] === "ERROR") + return final; + + if(pass1[0].indexOf("!") != -1) { + let nickuser = pass1[0].split('!'); + final.user.nickname = nickuser[0]; + let userhost = nickuser[1].split('@'); + final.user.username = userhost[0]; + final.user.hostname = userhost[1]; + } else { + final.user.hostname = pass1[0]; + } + + final.command = pass1[1]; + + let pass2 = pass1.slice(2).join(" "); + if(pass2.indexOf(":") != -1) { + final.arguments = pass2.substring(0, pass2.indexOf(' :')).split(" "); + final.trailing = pass2.substring(pass2.indexOf(':')+1); + } else { + final.arguments = pass2.split(" "); + } + + return final; +} diff --git a/teemant.js b/teemant.js index 8cd3bf5..24073b0 100755 --- a/teemant.js +++ b/teemant.js @@ -2,10 +2,15 @@ 'use strict'; let express = require("express"); let path = require("path"); -let sockio = require('socket.io'); +let sockio = require("socket.io"); +let dns = require("dns"); let app = express(); + let pubdir = path.join(__dirname+"/public"); -let port = 8080; +let config = require(__dirname+'/server/config'); +let ircclient = require(__dirname+'/server/irc'); + +let port = config.server.port || 8080; let connections = {} @@ -19,6 +24,16 @@ let io = sockio.listen(app.listen(port, function() { console.log("*** Listening on port " + port); })); +function resolveHostname(hostname) { + let promise = new Promise(function(resolve, reject) { + dns.lookup(hostname, function(err, address, family) { + if(err != null) return reject(err); + resolve(address); + }); + }); + return promise; +} + io.sockets.on('connection', function (socket) { console.log('clientID: '+socket.id+' connection: ', socket.request.connection._peername); connections[socket.id] = {} @@ -28,52 +43,69 @@ io.sockets.on('connection', function (socket) { }) socket.on('disconnect', function() { - for (let d in connections[socket.id]) d.disconnect("Client exited"); + for (let d in connections[socket.id]) + connections[socket.id][d].disconnect(); delete connections[socket.id]; console.log('clientID: '+socket.id+' disconnect'); }); + socket.on('error', (e) => { + console.log(e); + }) + socket.on('irc_create', function(connectiondata) { console.log(socket.id+" created irc connection: ", connectiondata); + socket.emit('act_client', {type: 'connect_message', message: "Connecting to server..", error: false}); - socket.emit('act_client', {type: 'connect_message', data: "Attempting connection..", error: false}); + let newConnection = new ircclient(connectiondata); + newConnection.connect(); - setTimeout(function() { - console.log("fake connect"); - socket.emit('act_client', {type: 'event_connect', address: connectiondata.server, network: "IcyNet", supportedModes: {"o": "@", "h": "%", "v": "+"}, nickname: connectiondata.nickname, raw: connectiondata}); - socket.emit('act_client', {type: 'server_message', messageType: "notice", server: connectiondata.server, message: "Connection established"}); - }, 2000); + connections[socket.id][connectiondata.server] = newConnection; - setTimeout(function() { - console.log("fake channel"); - socket.emit('act_client', {type: 'event_join_channel', server: connectiondata.server, channel: "#channel", user: {nickname: connectiondata.nickname, username: "teemant", hostname: socket.request.connection._peername.address}}); - socket.emit('act_client', {type: 'channel_nicks', channel: "#channel", server: connectiondata.server, nicks: ["+horse", "@scoper", "@aspire", "+random", "lol"]}); - socket.emit('act_client', {type: 'channel_topic', channel: "#channel", server: connectiondata.server, topic: "This channel is the best."}); - socket.emit('act_client', {type: 'channel_topic', channel: "#channel", server: connectiondata.server, set_by: "horse", time: Date.now()}); - socket.emit('act_client', {type: 'message', messageType: "privmsg", server: connectiondata.server, to: "#channel", user: {nickname: "horse"}, message: "I like ponies"}); + newConnection.on('authenticated', () => { + console.log("******** AUTH DONE **********"); + console.log("******** AUTH DONE **********"); + console.log("******** AUTH DONE **********"); + console.log("******** AUTH DONE **********"); + console.log("******** AUTH DONE **********"); + socket.emit('act_client', {type: "event_connect", address: connectiondata.server, network: newConnection.data.network, + supportedModes: newConnection.data.supportedModes, nickname: newConnection.config.nickname}); + }); - setTimeout(function() { - socket.emit('act_client', {type: 'nick_change', server: connectiondata.server, nick: "horse", newNick: "pony"}); - }, 2000) + newConnection.on('error', (data) => { + let message = "An error occured"; + let inconnect = false; - setTimeout(function() { - socket.emit('act_client', {type: 'message', messageType: "action", server: connectiondata.server, to: "#channel", user: {nickname: "pony"}, message: "Is the greatest pony fan"}); - }, 3000) + if(!newConnection.authenticated) { + message = "Failed to connect to the server!"; + inconnect = true; + } - setTimeout(function() { - socket.emit('act_client', {type: 'event_join_channel', server: connectiondata.server, channel: "#poni", user: {nickname: connectiondata.nickname, username: "teemant", hostname: socket.request.connection._peername.address}}); - socket.emit('act_client', {type: 'channel_nicks', channel: "#poni", server: connectiondata.server, nicks: ["+horse", "@Diamond", "@aspire", "+random", "lol"]}); - socket.emit('act_client', {type: 'channel_topic', channel: "#poni", server: connectiondata.server, topic: "This channel is the second best."}); - socket.emit('act_client', {type: 'channel_topic', channel: "#poni", server: connectiondata.server, set_by: "Diamond", time: Date.now()}); - }, 5000) + socket.emit('act_client', {type: (inconnect ? 'server_message' : 'connect_message'), message: message, type: 'error', error: true}); + }); - setTimeout(function() { - socket.emit('act_client', {type: 'event_kick_channel', server: connectiondata.server, channel: "#channel", user: {nickname: "scoper", username: "teemant", hostname: socket.request.connection._peername.address}, kickee: "random", reason: "Get out."}); - socket.emit('act_client', {type: 'event_quit', server: connectiondata.server, user: {nickname: "lol", username: "teemant", hostname: socket.request.connection._peername.address}, reason: "Sleep."}); - socket.emit('act_client', {type: 'event_part_channel', server: connectiondata.server, channel: "#poni", user: {nickname: "aspire", username: "teemant", hostname: socket.request.connection._peername.address}, reason: "Bye, lol."}); - }, 6000); - }, 4000); + newConnection.on('pass_to_client', (data) => { + socket.emit('act_client', data); + }); + + newConnection.on('closed', (data) => { + let message = "Connection closed"; + let inconnect = false; + + switch(data.type) { + case "sock_closed_success": + inconnect = true; + break; + } + + if(!newConnection.authenticated) { + message = "Failed to connect to the server!"; + inconnect = true; + } + + socket.emit('act_client', {type: (inconnect ? 'server_message' : 'connect_message'), message: message, type: 'error', error: true}); + }); }); });