view avatar.js @ 24:e32ac29df320

Don’t timeout two times.
author Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
date Sat, 16 Jul 2011 16:10:54 +0200
parents d63a2784564d
children ba0edeb9ba05
line wrap: on
line source

#!/usr/bin/env node

/*
 *  Copyright (C) 2011  Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
 *
 *  This file is the source code of an XMPP avatar retriever.
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as
 *  published by the Free Software Foundation, version 3 of the License.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

'use strict';

var config = require('./configuration');

var util = require('util');
var fs = require('fs');
var http = require('http');

if (config.useGravatar)
	var hash = require('crypto').createHash;

var xmpp = require('node-xmpp');
var conn = new xmpp.Client(config);
var Element = require('ltx').Element;

Element.prototype.getAttribute = function(name) {
	return this.attrs[name];
};

process.addListener('uncaughtException', function (err) {
	console.log('Uncaught exception (' + err + '), this should never happen:\n' + err.stack);
});

if (config.debug)
	(function() {
		var send = conn.send;
		conn.send = function(s) {
			console.log('Sent: ' + s + '');
			send.call(conn, s);
		};
	})();

conn.on('online', function () {
	util.log('Connected');
});

conn.on('stanza', function (stanza) {
	if (config.debug)
		console.log('Recv: ' + stanza + '');

	if (stanza.is('iq'))
		onIq(stanza);
	else
		onError(stanza);
});

var getUniqueId = (function() {
	var id = 0;
	return function() {
		return ++id;
	};
})();

var jids = {};

var sent = {};

var svgError = function(res, message) {
	util.log('No avatar at all, display a default image with the error in the title.');

	res.writeHead(200, {'Content-Type': 'image/svg+xml'});
	res.write('<?xml version="1.0" encoding="UTF-8"?>\n');
	res.write('<svg xmlns="http://www.w3.org/2000/svg"');

	if (config.defaultImage)
		res.write(' xmlns:xlink="http://www.w3.org/1999/xlink"');

	res.write(' viewBox="0 0 64 64">\n');
	res.write('\t<title>' + message + '</title>\n');

	if (config.defaultImage)
		res.write('\t<image width="64" height="64" xlink:href="' + config.defaultImage + '"/>\n');
	else {
		res.write('\t<rect width="64" height="64" fill="silver"/>\n');
		res.write('\t<circle cx="32" cy="26" r="14" fill="white"/>\n');
		res.write('\t<ellipse cx="32" cy="64" rx="24" ry="26" fill="white"/>\n');
	}

	res.end('</svg>\n');
}

var makeError = function(response) {
	response.attrs.type = 'error';

	response.c('error', {type: 'cancel'})
		.c('feature-not-implemented', {xmlns: 'urn:ietf:params:xml:ns:xmpp-stanzas'});

	return response;
}

if (config.useGravatar)
	var noAvatar = function(res, to, message, id) {
		if (id && sent[id])
			clearTimeout(sent[id].timeout);

		var options = {
			host: 'gravatar.com',
			port: 80,
			path: '/avatar/' + hash('md5').update(to).digest('hex') + '?d=404',
			method: 'GET'
		};

		util.log('No XMPP avatar or timeout, falling back to Gravatar for ' + to + '.');

		var r = http.request(options, function(r) {
			if (r.statusCode != 200)
				return svgError(res, message + ' Additionaly, no gravatar available.');

			var ext;
			var type = r.headers['content-type']
			for (var i in config.extensions)
				if (type == config.extensions[i])
					ext = i;

			var file = fs.createWriteStream(config.directory + '/' + to + '.' + ext);

			file.on('close', function() {
				jids[to] = ext;
				showImage(to, res);
			});

			r.on('data', function(chunk) {
				file.write(chunk);
			});

			r.on('end', function() {
				file.end();
			});
		});

		r.on('error', function(e) {
			return svgError(res, message + ' Additionaly, problem with gravatar request: ' + e.message);
		});

		return r.end();
	};
else
	var noAvatar = function(res, to, message, id) {
		if (id && sent[id])
			clearTimeout(sent[id].timeout);

		util.log('No XMPP avatar for ' + to + '.');
		return svgError(res, message);
	};

function onIq(stanza) {
	var type = stanza.getAttribute('type');
	var from = stanza.getAttribute('to');
	var to = stanza.getAttribute('from');
	var id = stanza.getAttribute('id');

	var response = new Element('iq', {to: to, from: from, type: 'result'});
	if (id)
		response.attrs.id = id;

	if (!sent[id])
		return conn.send(makeError(response));

	clearTimeout(sent[id].timeout);
	var res = sent[id].res;
	delete sent[id];

	if (type == 'error') {
		try {
			var err = stanza.getChild('error').getChild().name;
		} catch (e) {
			var err = 'none';
		}

		return noAvatar(res, to, 'Error during query of this user’s vCard: “'+err+'”.');
	}

	var vCard = stanza.getChild('vCard', 'vcard-temp');
	if (!vCard)
		return noAvatar(res, to, 'Error: this user doesn’t have a vCard.');

	try {
		var photo = vCard.getChild('PHOTO', 'vcard-temp');
		var base64 = photo.getChild('BINVAL', 'vcard-temp').getText();

		try {
			var type = photo.getChild('TYPE', 'vcard-temp').getText();
		} catch (e) {
			if (config.guessType)
				type = 'image/png'; // FIXME: use magic.
			else
				return noAvatar(res, to, 'Error: this user’s vCard doesn’t specify the MIME type of its avatar.');
		}

		var ext;
		for (var i in config.extensions)
			if (type == config.extensions[i])
				ext = i;

		// Here we don’t try to guess the extension even if the option is set.
		if (ext === undefined) {
			console.log('Unknown MIME type: '+type);
			return noAvatar(res, to, 'Error: this user’s avatar is in an unknown format.');
		}

		var binval = new Buffer(base64.replace(/\n/g, ''), 'base64');

		fs.writeFile(config.directory + '/' + to + '.' + ext, binval, function() {
			jids[to] = ext;
			showImage(to, res);
		});
	} catch (e) {
		return noAvatar(res, to, 'Error: this user doesn’t have an avatar in his/her vCard.');
	}
}

function onError(stanza) {
	if (stanza.getAttribute('type') == 'error')
		return;

	var from = stanza.getAttribute('to');
	var to = stanza.getAttribute('from');
	var id = stanza.getAttribute('id');

	var response = new Element(stanza.name, {to: to, from: from});
	if (id)
		response.attrs.id = id;

	conn.send(makeError(response));
}

var getVCard = function(jid, res) {
	var id = getUniqueId();

	var toSend = new Element('iq', {to: jid, from: config.jid, type: 'get', id: id})
		.c('vCard', {xmlns: 'vcard-temp'})
	.up();

	conn.send(toSend);

	sent[id] = {res: res, timeout: setTimeout(noAvatar, config.timeout, res, jid, 'Error: XMPP timeout.', id)};
}

var showImage = function(jid, res) {
	var extension = jids[jid];
	var file = config.directory+'/'+jid+'.'+extension;
	var now = new Date;
	res.writeHead(200, {'Content-Type': config.extensions[extension], Expires: new Date(config.expire * 1000 + now.getTime())});
	fs.readFile(file, function(err, data) {
		res.end(data);
	});
	fs.stat(file, function(err, stats) {
		if (err) {
			console.log('Error when stat on “'+file+'”.');
			return;
		}

		var last = new Date(stats.mtime);

		if (now - last > config.expire * 1000)
			getVCard(jid, res);
	});
	return;
}

fs.readdir(config.directory, function(err, files) {
	if (err)
		return fs.mkdir(config.directory, 488); // Actually 0750

	for (var i in files) {
		var tab = /(.*)\.([a-z]{3})/.exec(files[i]);
		jids[tab[1]] = tab[2];
	}
});

http.createServer(function (req, res) {
	util.log('Connection from ' + (req.headers['x-forwarded-for'] || req.client.remoteAddress) + ' (' + req.headers['user-agent'] + ') to ' + req.method.toLocaleLowerCase() + ' “' + req.url + '”.');

	var easterEggs = {
		source: {
			re: new RegExp('^' + config.webRoot + 'source/code$'),
			file: process.argv[1],
			mime: 'application/ecmascript',
			error: 'source code unavailable! oO'
		},
		README: {},
		COPYING: {},
		'index.xhtml': {
			re: /\/(index.xhtml)?$/,
			mime: 'application/xhtml+xml',
			error: '<h1 xmlns="http://www.w3.org/1999/xhtml">Index file not available.</h1>'
		}
	};

	req.setEncoding('utf-8');

	for (var i in easterEggs) {
		var ee = easterEggs[i];
		var file = ee.file || i;
		var re = ee.re || new RegExp('^' + config.webRoot + file + '$');
		if (re.test(req.url)) {
			fs.readFile(file, function(err, content) {
				if (err)
					return noAvatar(res, to, 'Error: ' + (ee.error || file + ' unavailable.'));

				res.writeHead(200, {'Content-Type': ee.mime || 'text/plain'});
				res.end(content);
			});
			return;
		}
	}

	var jid = unescape(req.url.replace(new RegExp('^' + config.webRoot), ''));

	if (jid === 'redirect') {
		if (req.method !== 'POST') {
			res.writeHead(404, {'Content-Type': 'text/plain'});
			res.end('Error: redirect unavailable.');
			return;
		}

		req.on('data', function(content) {
			var jid = unescape(content.toString()).replace(/^jid=/, '');
			res.writeHead(301, {'Location': jid});
			res.end();
		});
		return;
	}

	if (jid in jids) {
		showImage(jid, res);
		return;
	}

	getVCard(jid, res);
}).listen(config.webPort, config.webHost);