ADDED - Presence tracking for chat and muc FIXED - MUC now works properly ADDED - Time and Last IQ ADDED - Idle timemaster
							parent
							
								
									d5b97a21b0
								
							
						
					
					
						commit
						027dd2fee5
					
				| @ -1,3 +1,8 @@ | ||||
| exports.BasicClient = require('./xmpp-client/basic-client').BasicClient; | ||||
| exports.Client = require('./xmpp-client/client').Client; | ||||
| exports.Jid    = require('./xmpp-client/client').Jid; | ||||
| exports.Room    = require('./xmpp-client/room').Room; | ||||
| exports.BytestreamServer    = require('./xmpp-client/bytestream-server').BytestreamServer; | ||||
| exports.Bytestream    = require('./xmpp-client/bytestream').Bytestream; | ||||
| exports.Pubsub    = require('./xmpp-client/bytestream').Pubsub; | ||||
| exports.XMLNS    = require('./xmpp-client/xmlns'); | ||||
| @ -0,0 +1,141 @@ | ||||
| var sys		= require("sys"), | ||||
| 	net		= require("net"), | ||||
| 	XMLNS	= require("./xmlns.js"), | ||||
| 	xmpp	= require("node-xmpp"), | ||||
| 	colors	= require("colors"), | ||||
| 	events	= require("events"); | ||||
| 
 | ||||
| var BytestreamServer	= function(client) { | ||||
| 	var self			= this; | ||||
| 	this.client		 	= client; | ||||
| 	this.port			= (client.params.s5bPort == null) ? 8010 : client.params.s5bPort; | ||||
| 	this.host			= (client.params.s5bHost == null) ? "proxy." + client.params.host : client.params.s5bHost; | ||||
| 	this._clients		= {}; | ||||
| 	this._handlers		= {}; | ||||
| 	this.tcpSocket		= net.createServer(function(stream) {self.handleConnection(stream);}); | ||||
| 	this.tcpSocket.on("error", function(error) { | ||||
| 		console.log("Error with S5B server!"); | ||||
| 		console.log(error); | ||||
| 	}); | ||||
| 	 | ||||
| 	this.tcpSocket.listen(this.port, "0.0.0.0"); | ||||
| }; | ||||
| 
 | ||||
| exports.BytestreamServer = BytestreamServer; | ||||
| 
 | ||||
| BytestreamServer.prototype.close	= function() { | ||||
| 	for (ii in this._clients) { | ||||
| 		this._clients[ii].end(); | ||||
| 	} | ||||
| 	 | ||||
| 	this.tcpSocket.close(); | ||||
| }; | ||||
| 
 | ||||
| BytestreamServer.prototype.addHandler	= function(iqId, sidHash, data, cbSuccess, cbFailure) { | ||||
| 	this._handlers[sidHash]	= {id: iqId, data: data, cbSuccess:cbSuccess, cbFailure:cbFailure}; | ||||
| }; | ||||
| 
 | ||||
| BytestreamServer.prototype.handleConnection	= function(stream) { | ||||
| 	var self							= this; | ||||
| 	this._clients[stream.remoteAddress]	= new BytestreamHandler(stream, self); | ||||
| 	this._clients[stream.remoteAddress].established	= false; | ||||
| 	this._clients[stream.remoteAddress].transfered	= false; | ||||
| 	 | ||||
| 	stream.on("error", function(error) { | ||||
| 		console.log("S5B stream error from "+ stream.remoteAddress); | ||||
| 		console.log(error); | ||||
| 		 | ||||
| 		stream.end(); | ||||
| 		delete self._clients[stream.remoteAddress]; | ||||
| 	}); | ||||
| 	 | ||||
| 	stream.on("connect", this._clients[stream.remoteAddress].onConnect); | ||||
| 	stream.on("data", this._clients[stream.remoteAddress].onData); | ||||
| 	stream.on("end", this._clients[stream.remoteAddress].onEnd); | ||||
| }; | ||||
| 
 | ||||
| BytestreamServer.prototype.iq	= function(bytestream, query, to, id) { | ||||
| 	var self	= this; | ||||
| 	var jabber	= self.client; | ||||
| 	 | ||||
| 	if (typeof to === "undefined") { | ||||
| 		var to	= bytestream.to; | ||||
| 	} | ||||
| 	 | ||||
| 	if (typeof id === "undefined") { | ||||
| 		var id	= bytestream.idIq; | ||||
| 	} | ||||
| 	 | ||||
| 	var params	= { | ||||
| 		type: "set", | ||||
| 		id: id, | ||||
| 		to: to | ||||
| 	}; | ||||
| 	 | ||||
| 	jabber.xmpp.send(new xmpp.Element('iq', params).cnode(query).tree()); | ||||
| }; | ||||
| 
 | ||||
| var BytestreamHandler	= function(self, parent) { | ||||
| 	self.parent	= parent; | ||||
| 	self.iqId	= null; | ||||
| 	 | ||||
| 	this.onConnect	= function(data) {}; | ||||
| 	 | ||||
| 	this.onData	= function(data) { | ||||
| 		// TODO: Disconnect client when BS data is entered
 | ||||
| 		// just a start
 | ||||
| 		if (data[0] !== 0x05 && data[1] > 0x0F) { | ||||
| 			console.log("Received erroneous data from " + self.remoteAddress); | ||||
| 			self.end(); | ||||
| 		} | ||||
| 		 | ||||
| 		if (self.parent._clients[self.remoteAddress].established === false | ||||
| 		&& data.length >= 1 && data[0] === 0x05) { | ||||
| 			if (data.length >= 3 && (data[1] === 0x01 || data[1] === 0x02) && data[2] === 0x00) { | ||||
| 				if (data[1] === 0x01 || (data.length >= 4 && data[3] === 0x02)) { | ||||
| 					self.parent._clients[self.remoteAddress].established = true; | ||||
| 					 | ||||
| 					self.write(new Buffer([0x05,0x00])); // Ack
 | ||||
| 				} | ||||
| 			} | ||||
| 		} else if (self.parent._clients[self.remoteAddress].established === true && data.length == 47 | ||||
| 		&& data[data.length - 1] === 0x00 && data[data.length - 2] === 0x00) { | ||||
| 			var reqHash	= data.toString("ascii", 5, 45); | ||||
| 			if ((reqHash in self.parent._handlers) === true) { | ||||
| 				var buff	= [0x05,0x00,0x00,0x03]; // Request header
 | ||||
| 				buff.push(reqHash.length); // Announce data length
 | ||||
| 				// Push our reqHash in the buffer
 | ||||
| 				reqHash.split("").forEach(function(val) { | ||||
| 					buff.push(val.charCodeAt(0)); | ||||
| 				}); | ||||
| 				 | ||||
| 				// DST.PORT is two bytes
 | ||||
| 				buff.push(0x00, 0x00); | ||||
| 				 | ||||
| 				// Add iq handler for receiving the target's ack
 | ||||
| 				self.iqId	= self.parent._handlers[reqHash].id; | ||||
| 				self.parent.client._iqCallback[self.iqId]			= {}; | ||||
| 				self.parent.client._iqCallback[self.iqId].success	= function(data) { | ||||
| 					if (typeof self.parent._handlers[reqHash].data === "function") { | ||||
| 						self.parent._handlers[reqHash].data(self); | ||||
| 					} else { | ||||
| 						self.write(self.parent._handlers[reqHash].data); | ||||
| 					} | ||||
| 					 | ||||
| 					self.parent._clients[self.remoteAddress].transfered	= true; | ||||
| 					delete self.parent.client._iqCallback[self.iqId]; | ||||
| 				}; | ||||
| 				 | ||||
| 				self.parent.client._iqCallback[self.iqId].error	= function(error) { | ||||
| 					console.log(error); | ||||
| 				}; | ||||
| 				 | ||||
| 				self.write(new Buffer(buff)); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	this.onEnd	= function(data) { | ||||
| 		delete self.parent._clients[self.remoteAddress]; | ||||
| 	}; | ||||
| }; | ||||
| @ -0,0 +1,214 @@ | ||||
| var sys		= require("sys"), | ||||
| 	net		= require("net"), | ||||
| 	XMLNS	= require("./xmlns.js"), | ||||
| 	xmpp	= require("node-xmpp"), | ||||
| 	colors	= require("colors"), | ||||
| 	events	= require("events"), | ||||
| 	hash	= require("../../../node_hash/lib/hash"); | ||||
| 
 | ||||
| var Bytestream	= function(server, file, to, idIq) { | ||||
| 	var bytestream	= this; | ||||
| 	this.server		= server; | ||||
| 	this.client		= server.client; | ||||
| 	this.idIq		= idIq; | ||||
| 	this.to			= to; | ||||
| 	this.sidId		= this.idIq + "_" + (new Date().getTime()); | ||||
| 	this.sidHash	= hash.sha1(this.sidId + this.client.jid.toString() + this.to); | ||||
| 	this.file		= file; | ||||
| 	/* | ||||
| 	// FIXME
 | ||||
| 	if (("mimeType" in this.file) === false) { | ||||
| 		this.file.mimeType	= "plain/text"; | ||||
| 	} | ||||
| 	 | ||||
| 	if (("length" in this.file) === false || typeof this.file.data.length !== "undefined") { | ||||
| 		this.file.length	= this.file.data.length; | ||||
| 	} | ||||
| 	*/ | ||||
| 	 | ||||
| 	this.sendInitiate(); | ||||
| }; | ||||
| 
 | ||||
| sys.inherits(Bytestream, events.EventEmitter); | ||||
| 
 | ||||
| exports.Bytestream = Bytestream; | ||||
| 
 | ||||
| Bytestream.prototype.sendInitiate	= function() { | ||||
| 	var self	= this; | ||||
| 	var jabber	= self.client; | ||||
| 	 | ||||
| 	// Define the iqCallback for the negotiation stream
 | ||||
| 	jabber._iqCallback[self.idIq] = {}; | ||||
| 	jabber._iqCallback[self.idIq].success	= function(jabber) { | ||||
| 		self.sendFile(); | ||||
| 	}; | ||||
| 	 | ||||
| 	jabber._iqCallback[self.idIq].error		= function(error) { | ||||
| 		var declined	= false; | ||||
| 		if (typeof error !== "undefined") { | ||||
| 			var error	= error.getChild("error"); | ||||
| 			 | ||||
| 			if (typeof error.getChild("forbidden") !== "undefined") { | ||||
| 				self.client.message(self.to, "Ok, maybe some other time!"); | ||||
| 				declined	= true; | ||||
| 			} | ||||
| 		} | ||||
| 		 | ||||
| 		if (declined !== true) { | ||||
| 			console.log(error.toString()); | ||||
| 		} | ||||
| 		 | ||||
| 		delete jabber._iqCallback[self.idIq]; | ||||
| 	}; | ||||
| 	 | ||||
| 	// Build and send the data negotiation request
 | ||||
| 	self.server.iq(self, | ||||
| 		new xmpp.Element | ||||
| 		("si", {xmlns: XMLNS.SI, profile: XMLNS.SI_PROFILE, id: self.sidId, "mime-type": self.file.mimeType}) | ||||
| 			.c("file", {xmlns: XMLNS.SI_PROFILE, size: self.file.length, name: self.file.name}) | ||||
| 				.c("desc").t((typeof self.file.desc !== "undefined" ? self.file.desc : "")).up() | ||||
| 				.c("range").up() | ||||
| 			.up() | ||||
| 			.c("feature", {xmlns: XMLNS.FEATURE_NEG}) | ||||
| 				.c("x", {xmlns: XMLNS.DATA, type: "form"}) | ||||
| 					.c("field", {type: "list-single", "var": "stream-method"}) | ||||
| 						.c("option") | ||||
| 							.c("value").t(XMLNS.BYTESTREAMS) | ||||
| 							.up() | ||||
| 						.up() | ||||
| 					.up() | ||||
| 				.up() | ||||
| 			.up() | ||||
| 		.up() | ||||
| 		.tree() | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| Bytestream.prototype.sendFile	= function() { | ||||
| 	var self	= this; | ||||
| 	var jabber	= self.client; | ||||
| 	 | ||||
| 	// Define the callback for streamhost negotiation
 | ||||
| 	// This might get overwritten is the internal bytestream server is contacted by the client
 | ||||
| 	jabber._iqCallback[self.idIq] = {}; | ||||
| 	jabber._iqCallback[self.idIq].success	= function(stanza) { | ||||
| 		if (typeof stanza.getChild("query") !== "undefined" | ||||
| 		&& typeof stanza.getChild("query").getChild("streamhost-used") !== "undefined") { | ||||
| 			var usedStream	= stanza.getChild("query").getChild("streamhost-used").attrs.jid.toString(); | ||||
| 			// We connected through the local S5B
 | ||||
| 			if (usedStream	=== self.client.jid.toString()) { | ||||
| 				// Nothing here really, we only got this after the session is ready and this has been overwritten
 | ||||
| 				// Connected through the server's S5B proxy
 | ||||
| 			} else { | ||||
| 				self.sendProxy(usedStream); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
| 	 | ||||
| 	jabber._iqCallback[self.idIq].error		= function(error) { | ||||
| 		console.log(error); | ||||
| 		 | ||||
| 		delete jabber._iqCallback[self.idIq]; | ||||
| 	}; | ||||
| 	 | ||||
| 	self.server.iq(self,  | ||||
| 		new xmpp.Element | ||||
| 		('query', {xmlns: XMLNS.BYTESTREAMS, sid: self.sidId}) | ||||
| // S5B Direct
 | ||||
| 			.c("streamhost", {jid: jabber.jid.toString(), host: self.server.host, port: self.server.port}).up() | ||||
| // S5B Proxy
 | ||||
| 			.c("streamhost", {jid: self.client.params.proxyJid, host: self.client.params.proxyHost, port: self.client.params.proxyPort}).up() | ||||
| 		.tree() | ||||
| 	); | ||||
| 	 | ||||
| 	self.server.addHandler(self.idIq, self.sidHash, self.file.data); | ||||
| }; | ||||
| 
 | ||||
| Bytestream.prototype.sendProxy	= function(streamProxy) { | ||||
| 	var self	= this; | ||||
| 	var jabber	= this.client; | ||||
| 	 | ||||
| 	var client	= net.createConnection(7777, streamProxy); | ||||
| 	 | ||||
| 	client.addListener("error", function(error) { | ||||
| 		console.log("Error with S5B proxy connection for "+ self.to +" "+ client.remoteAddress); | ||||
| 		console.log(error); | ||||
| 	}); | ||||
| 	 | ||||
| 	client.addListener("connect", function() { | ||||
| 		var connected	= false; | ||||
| 		 | ||||
| 		client.write(new Buffer([0x05,0x01,0x00])); // CONNECT
 | ||||
| 		 | ||||
| 		client.addListener("data", function(data) { | ||||
| 			if (data[0] !== 0x05) { | ||||
| 				return; | ||||
| 			} | ||||
| 			 | ||||
| 			// Ack
 | ||||
| 			if (connected === false && data.length === 0x02 && data[1] === 0x00) { | ||||
| 				var buff	= [0x05,0x01,0x0,0x03]; // Request header
 | ||||
| 				 | ||||
| 				buff.push(self.sidHash.length); // Announce data length
 | ||||
| 				 | ||||
| 				// Push our sidHash in the buffer
 | ||||
| 				self.sidHash.split("").forEach(function(val) { | ||||
| 					buff.push(val.charCodeAt(0)); | ||||
| 				}); | ||||
| 				 | ||||
| 				// DST.PORT is two bytes
 | ||||
| 				buff.push(0x00, 0x00); | ||||
| 				 | ||||
| 				client.write(new Buffer(buff)); | ||||
| 				 | ||||
| 				connected	= true; | ||||
| 			} else if (connected === true && data.length == 47 && data[1] === 0x00) { | ||||
| 				// Request Activate
 | ||||
| 				var reqHash	= data.toString("ascii", 5, 5 + self.sidHash.length); | ||||
| 				 | ||||
| 				if (reqHash === self.sidHash) { | ||||
| 					var iqId	= "S5B_"+ (new Date().getTime()).toString(); | ||||
| 					 | ||||
| 					// Register activation callback
 | ||||
| 					jabber._iqCallback[iqId] = {}; | ||||
| 					jabber._iqCallback[iqId].success	= function(stanza) { | ||||
| 						if (stanza.attrs.from.toString() === streamProxy | ||||
| 						&& stanza.attrs.type.toString() === "result") { | ||||
| 							// Send data! Finally!
 | ||||
| 							if (typeof self.file.data === "function") { | ||||
| 								self.file.data(client); | ||||
| 							} else { | ||||
| 								client.write(self.file.data); | ||||
| 							} | ||||
| 							 | ||||
| 							delete jabber._iqCallback[self.iqId]; | ||||
| 						} | ||||
| 					}; | ||||
| 					 | ||||
| 					jabber._iqCallback[iqId].error		= function(error) { | ||||
| 						console.log("Activation error"); | ||||
| 						console.log(error.toString()); | ||||
| 						 | ||||
| 						delete jabber._iqCallback[self.idIq]; | ||||
| 					}; | ||||
| 					 | ||||
| 					self.server.iq(self, | ||||
| 						new xmpp.Element('query', {xmlns: XMLNS.BYTESTREAMS, sid: self.sidId}) | ||||
| 							.c("activate").t(self.to) | ||||
| 						.up() | ||||
| 						.tree(), | ||||
| 						streamProxy, | ||||
| 						iqId | ||||
| 					); | ||||
| 				} | ||||
| 			} else { | ||||
| 				console.log(data); | ||||
| 			} | ||||
| 		}); | ||||
| 		 | ||||
| 		client.addListener("close", function(data) { | ||||
| 			sys.puts("Disconnected from server"); | ||||
| 		}); | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| @ -1,59 +1,95 @@ | ||||
| var sys = require('sys'), | ||||
| 	xmpp = require('node-xmpp'), | ||||
| 	colors = require('colors'), | ||||
| 	events = require('events'); | ||||
| var sys = require("sys"), | ||||
| 	xmpp = require("node-xmpp"), | ||||
| 	colors = require("colors"), | ||||
| 	events = require("events"); | ||||
| 
 | ||||
| var NS_MUC	= "http://jabber.org/protocol/muc"; | ||||
| 
 | ||||
| var Room = function(client, name, callback) { | ||||
| 	events.EventEmitter.call(this); | ||||
| 	this._isReady = false; | ||||
| 	this.client = client; | ||||
| 	this.room = name; | ||||
| 	this.to = this.room + '/' + this.client.jid.user; | ||||
| 	this.to = this.room + "/" + this.client.params.nickname; | ||||
| 	 | ||||
| 	var room = this; | ||||
| 	this.addListener('presence', function(from, stanza) { | ||||
| 	this.addListener("presence", function(from, stanza) { | ||||
| 		var jfrom = new xmpp.JID(from); | ||||
| 		if(name == jfrom.user + '@' + jfrom.domain) { | ||||
| 			var x = stanza.getChild('x', 'http://jabber.org/protocol/muc#user'); | ||||
| 		if(name == jfrom.user + "@" + jfrom.domain) { | ||||
| 			var x = stanza.getChild("x", NS_MUC + "#user"); | ||||
| 			if(x != null) { | ||||
| 				var item = x.getChild('item'); | ||||
| 				var item = x.getChild("item"); | ||||
| 				if(item != null) { | ||||
| 					room.affiliation = item.attrs.affiliation; | ||||
| 					room.role = item.attrs.role; | ||||
| 				} | ||||
| 				var status = x.getChild('status'); | ||||
| 				var status = x.getChild("status"); | ||||
| 				if(! room._isReady) { | ||||
| 					room._isReady = true; | ||||
| 					callback.call(room, (status != null) ? status.attrs.code : '200'); | ||||
| 					callback.call(room, (status != null) ? status.attrs.code : "200"); | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}); | ||||
| 	this.presence(); | ||||
| 	 | ||||
| 	this.presence("dnd", "Loading..."); | ||||
| 	this.client.emit("muc:binded"); | ||||
| }; | ||||
| 
 | ||||
| sys.inherits(Room, events.EventEmitter); | ||||
| 
 | ||||
| exports.Room = Room; | ||||
| 
 | ||||
| Room.prototype.presence = function() { | ||||
| 	sys.debug(('presence: ' + this.room)[this.client.color]); | ||||
| 	this.client.xmpp.send(new xmpp.Element('presence', { | ||||
| Room.prototype.getPresences	= function() | ||||
| { | ||||
| 	return this.client.presences.muc; | ||||
| }; | ||||
| 
 | ||||
| Room.prototype.presence = function(status, message) | ||||
| { | ||||
| 	if (typeof status === "undefined") | ||||
| 	{ | ||||
| 		var status	= ""; | ||||
| 	} | ||||
| 	 | ||||
| 	if (typeof message === "undefined") | ||||
| 	{ | ||||
| 		var message	= ""; | ||||
| 	} | ||||
| 	 | ||||
| 	this.client.xmpp.send(new xmpp.Element("presence", { | ||||
| 			to: this.to | ||||
| 		}) | ||||
| 		.c('priority').t("5").up() | ||||
| 		.c('x', {xmlns:"http://jabber.org/protocol/muc"}) | ||||
| 		.c("priority").t("5").up() | ||||
| 		.c("show").t(status).up() | ||||
| 		.c("status").t(message).up() | ||||
| 		.c("x", {xmlns: NS_MUC}) | ||||
| 		.tree() | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| Room.prototype.changenick = function(newnick) | ||||
| { | ||||
| 	this.client.params.nickname	= newnick; | ||||
| 	this.client.unidle(); | ||||
| 	 | ||||
| 	this.client.xmpp.send(new xmpp.Element("presence", { | ||||
| 			to: this.to.split("/")[0]+"/"+this.client.params.nickname | ||||
| 		}) | ||||
| 		.c("x", {xmlns: NS_MUC}) | ||||
| 		.tree() | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| Room.prototype.message = function(message) { | ||||
| 	this.client.xmpp.send(new xmpp.Element('message', { | ||||
| 	this.client.unidle(); | ||||
| 	this.client.xmpp.send(new xmpp.Element("message", { | ||||
| 			to: this.room, | ||||
| 			type: 'groupchat', | ||||
| 			type: "groupchat", | ||||
| 			id: this.client._iq++ | ||||
| 		}) | ||||
| //		.c('nick', {xmlns: 'http://jabber.org/protocol/nick'}).t(this.client.jid.username).up()
 | ||||
| 		.c('body').t(message).up() | ||||
| //		.c("nick", {xmlns: "http://jabber.org/protocol/nick"}).t(this.client.jid.username).up()
 | ||||
| 		.c("body").t(message).up() | ||||
| 		.tree() | ||||
| 		); | ||||
| }; | ||||
|  | ||||
| @ -0,0 +1,22 @@ | ||||
| /** | ||||
| * XMPP XMLNS List | ||||
| */ | ||||
| exports.SASL			= "urn:ietf:params:xml:ns:xmpp-sasl"; | ||||
| exports.BIND			= "urn:ietf:params:xml:ns:xmpp-bind"; | ||||
| exports.SESSION			= "urn:ietf:params:xml:ns:xmpp-session"; | ||||
| exports.CLIENT			= "jabber:client"; | ||||
| exports.XHTML			= "http://www.w3.org/1999/xhtml"; | ||||
| exports.XHTML_IM		= "http://jabber.org/protocol/xhtml-im"; | ||||
| exports.TLS				= "urn:ietf:params:xml:ns:xmpp-tls"; | ||||
| exports.STREAMS			= "http://etherx.jabber.org/streams"; | ||||
| exports.BYTESTREAMS		= "http://jabber.org/protocol/bytestreams"; | ||||
| exports.ROASTER			= "jabber:iq:roster"; | ||||
| exports.VCARD			= "vcard-temp"; | ||||
| exports.VCARD_UPDATE	= "vcard-temp:x:update"; | ||||
| exports.SI				= "http://jabber.org/protocol/si"; | ||||
| exports.SI_PROFILE		= "http://jabber.org/protocol/si/profile/file-transfer"; | ||||
| exports.FEATURE_NEG		= "http://jabber.org/protocol/feature-neg"; | ||||
| exports.DATA			= "jabber:x:data"; | ||||
| exports.AFFINIX_STREAM	= "http://affinix.com/jabber/stream"; | ||||
| exports.CHAT_STATE		= "http://jabber.org/protocol/chatstates"; | ||||
| exports.IQ_LAST			= "jabber:iq:last"; | ||||
					Loading…
					
					
				
		Reference in new issue
	
	 Matthieu Lalonde
						Matthieu Lalonde