You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
doushio/server/server.js

1280 lines
33 KiB

var opts = require('./opts');
if (require.main == module) opts.parse_args();
opts.load_defaults();
const khash = require("kana-hash");
var _ = require('../lib/underscore'),
amusement = require('./amusement'),
async = require('async'),
auth = require('./auth'),
caps = require('./caps'),
check = require('./msgcheck').check,
common = require('../common'),
config = require('../config'),
crypto = require('crypto'),
db = require('../db'),
fs = require('fs'),
hooks = require('../hooks'),
imager = require('../imager'),
etc = require('../etc'),
Muggle = etc.Muggle,
okyaku = require('./okyaku'),
render = require('./render'),
request = require('request'),
STATE = require('./state'),
tripcode = require('../tripcode').make(),//{hash:function(a,b){return new khash.Kana(0, this.salt).once(a);}, setSalt:function(a){if(a) this.salt = new khash.Salt(a); else this.salt = khash.Salt.Default; return this.salt;}}, //require('./../tripcode/tripcode'),
urlParse = require('url').parse,
web = require('./web'),
winston = require('winston');
require('../admin');
if (!imager.is_standalone())
require('../imager/daemon'); // preload and confirm it works
if (config.CURFEW_BOARDS)
require('../curfew/server');
try {
var reportConfig = require('../report/config');
if (reportConfig.RECAPTCHA_SITE_KEY)
require('../report/server');
} catch (e) {}
var RES = STATE.resources;
var dispatcher = okyaku.dispatcher;
/* I always use encodeURI anyway */
var escape = common.escape_html;
var safe = common.safe;
dispatcher[common.PING] = function (msg, client) {
if (msg.length)
return false;
client.send([0, common.PING]);
return true;
};
dispatcher[common.SYNCHRONIZE] = function (msg, client) {
function checked(err, ident) {
if (!err)
_.extend(client.ident, ident);
if (!synchronize(msg, client))
client.kotowaru(Muggle("Bad protocol."));
}
var chunks = web.parse_cookie(msg.pop());
var cookie = auth.extract_login_cookie(chunks);
if (cookie) {
auth.check_cookie(cookie, checked);
return true;
}
else
return synchronize(msg, client);
};
function synchronize(msg, client) {
if (!check(['id', 'string', 'id=>nat', 'boolean'], msg))
return false;
var id = msg[0], board = msg[1], syncs = msg[2], live = msg[3];
if (id in STATE.clients) {
winston.error("Duplicate client id " + id);
return false;
}
client.id = id;
STATE.clients[id] = client;
if (!caps.can_access_board(client.ident, board))
return false;
var dead_threads = [], count = 0, op;
for (var k in syncs) {
k = parseInt(k, 10);
if (db.OPs[k] != k || !db.OP_has_tag(board, k)) {
delete syncs[k];
dead_threads.push(k);
}
op = k;
if (++count > config.THREADS_PER_PAGE) {
/* Sync logic isn't great yet; allow this for now */
// return false;
}
}
client.watching = syncs;
if (live) {
/* XXX: This will break if a thread disappears during sync
* (won't be reported)
* Or if any of the threads they see on the first page
* don't show up in the 'live' pub for whatever reason.
* Really we should get them synced first and *then* switch
* to the live pub.
*/
client.watching = {live: true};
count = 1;
}
client.board = board;
if (client.db)
client.db.disconnect();
client.db = new db.Yakusoku(board, client.ident);
/* Race between subscribe and backlog fetch; client must de-dup */
client.db.kiku(client.watching, client.on_update.bind(client),
client.on_thread_sink.bind(client), listening);
function listening(errs) {
if (errs && errs.length >= count)
return client.kotowaru(Muggle(
"Couldn't sync to board."));
else if (errs) {
dead_threads.push.apply(dead_threads, errs);
errs.forEach(function (thread) {
delete client.watching[thread];
});
}
client.db.fetch_backlogs(client.watching, got_backlogs);
}
function got_backlogs(errs, logs) {
if (errs) {
dead_threads.push.apply(dead_threads, errs);
errs.forEach(function (thread) {
delete client.watching[thread];
});
}
if (client.ident.readOnly) {
logs.push('0,' + common.MODEL_SET + ',["hot"],{"readOnly":true}');
}
var sync = '0,' + common.SYNCHRONIZE;
if (dead_threads.length)
sync += ',' + JSON.stringify(dead_threads);
logs.push(sync);
client.socket.write('[[' + logs.join('],[') + ']]');
client.synced = true;
var info = {client: client, live: live};
if (!live && count == 1)
info.op = op;
else
info.board = board;
hooks.trigger('clientSynced', info, function (err) {
if (err)
winston.error(err);
});
}
return true;
}
function setup_imager_relay(cb) {
var onegai = new imager.Onegai;
onegai.relay_client_messages();
onegai.once('relaying', function () {
onegai.on('message', image_status);
cb(null);
});
}
function image_status(client_id, status) {
if (!check('id', client_id))
return;
var client = STATE.clients[client_id];
if (client) {
try {
client.send([0, common.IMAGE_STATUS, status]);
}
catch (e) {
// Swallow EINTR
// anta baka?
}
}
}
function page_nav(thread_count, cur_page, ascending) {
var page_count = Math.ceil(thread_count / config.THREADS_PER_PAGE);
page_count = Math.max(page_count, 1);
var info = {pages: page_count, threads: thread_count,
cur_page: cur_page, ascending: ascending};
var step = ascending ? -1 : 1;
var next = Math.max(cur_page, 0) + step;
if (next >= 0 && next < page_count)
info.next_page = 'page' + next;
var prev = cur_page - step;
if (prev >= 0 && prev < page_count)
info.prev_page = 'page' + prev;
return info;
}
function write_gzip_head(req, resp, headers) {
var encoding = config.GZIP && req.headers['accept-encoding'];
if (req.ident.slow || !encoding || encoding.indexOf('gzip') < 0) {
resp.writeHead(200, headers);
return resp;
}
resp.writeHead(200, _.extend({}, headers, {
'Content-Encoding': 'gzip',
Vary: 'Accept-Encoding',
}));
var gz = require('zlib').createGzip();
gz.pipe(resp);
return gz;
}
function redirect_thread(cb, num, op, tag) {
if (!tag)
cb(null, 'redirect', op + '#' + num);
else
/* Use a JS redirect to preserve the hash */
cb(null, 'redirect_js', '../' + tag + '/' + op + '#' + num);
}
// unless imager.config.DAEMON, we deal with image uploads in-process.
if (!imager.is_standalone()) {
web.route_post(/^\/upload\/$/, require('../imager/daemon').new_upload);
}
web.resource(/^\/$/, function (req, cb) {
cb(null, 'redirect', config.DEFAULT_BOARD + '/');
});
if (config.DEBUG) {
/* Shortcuts for convenience */
winston.warn("Running in (insecure) debug mode.");
winston.warn("Do not use on the public internet.");
web.route_get(/^\/login$/, function (req, resp) {
auth.set_cookie(req, resp, {auth: 'Admin'});
});
web.route_get(/^\/mod$/, function (req, resp) {
auth.set_cookie(req, resp, {auth: 'Moderator'});
});
}
else {
/* Production login endpoint */
web.route_get(/^\/login$/, auth.login);
if (config.SERVE_STATIC_FILES)
winston.warn("Recommended: nginx-like webserver instead of SERVE_STATIC_FILES.");
if (config.SERVE_IMAGES)
winston.warn("Recommended: nginx-like webserver instead of SERVE_IMAGES.");
}
web.route_get(/^\/logout$/, auth.logout);
web.route_post(/^\/logout$/, auth.logout);
function write_mod_js(resp, ident) {
if (!RES.modJs) {
resp.writeHead(500);
resp.end('Mod js not built?!');
return;
}
var noCacheJs = _.clone(web.noCacheHeaders);
noCacheJs['Content-Type'] = 'text/javascript; charset=UTF-8';
resp.writeHead(200, noCacheJs);
resp.write('(function (IDENT) {');
resp.write(RES.modJs);
resp.end('})(' + JSON.stringify(ident) + ');');
}
web.resource_auth(/^\/admin\.js$/, function (req, cb) {
if (!caps.can_administrate(req.ident))
cb(404);
else
cb(null, 'ok');
},
function (req, resp) {
write_mod_js(resp, {
auth: req.ident.auth,
csrf: req.ident.csrf,
user: req.ident.user,
});
});
web.resource_auth(/^\/mod\.js$/, function (req, cb) {
if (!caps.can_moderate(req.ident))
cb(404);
else
cb(null, 'ok');
},
function (req, resp) {
write_mod_js(resp, {
auth: req.ident.auth,
csrf: req.ident.csrf,
user: req.ident.user,
});
});
web.resource(/^\/(\w+)$/, function (req, params, cb) {
var board = params[1];
/* If arbitrary boards were allowed, need to escape this: */
var dest = board + '/';
if (req.ident.suspension)
return cb(null, 'redirect', dest); /* TEMP */
if (!caps.can_ever_access_board(req.ident, board))
return cb(404);
cb(null, 'redirect', dest);
});
web.resource(/^\/(\w+)\/live$/, function (req, params, cb) {
if (req.ident.suspension)
return cb(null, 'redirect', '.'); /* TEMP */
if (!caps.can_ever_access_board(req.ident, params[1]))
return cb(404);
cb(null, 'redirect', '.');
});
web.resource(/^\/(\w+)\/$/, function (req, params, cb) {
var board = params[1];
if (req.ident.suspension)
return cb(null, 'ok'); /* TEMP */
if (!caps.can_ever_access_board(req.ident, board))
return cb(404);
cb(null, 'ok', {board: board});
},
function (req, resp) {
/* TEMP */
if (req.ident.suspension)
return render_suspension(req, resp);
var board = this.board;
var info = {board: board, ident: req.ident, resp: resp};
hooks.trigger_sync('boardDiversion', info);
if (info.diverted)
return;
var yaku = new db.Yakusoku(board, req.ident);
yaku.get_tag(-1);
var paginationHtml;
yaku.once('begin', function (thread_count) {
var nav = page_nav(thread_count, -1, board == 'archive');
var initScript = make_init_script(req.ident);
render.write_board_head(resp, initScript, board, nav);
paginationHtml = render.make_pagination_html(nav);
resp.write(paginationHtml);
resp.write('<hr>\n');
});
resp = write_gzip_head(req, resp, web.noCacheHeaders);
var opts = {fullLinks: true, board: board};
render.write_thread_html(yaku, req, resp, opts);
yaku.once('end', function () {
resp.write(paginationHtml);
render.write_page_end(resp, req.ident, false);
resp.end();
yaku.disconnect();
});
yaku.once('error', function (err) {
winston.error('index:' + err);
resp.end();
yaku.disconnect();
});
});
web.resource(/^\/(\w+)\/page(\d+)$/, function (req, params, cb) {
var board = params[1];
if (!caps.temporal_access_check(req.ident, board))
return cb(null, 302, '..');
if (req.ident.suspension)
return cb(null, 'ok'); /* TEMP */
if (!caps.can_access_board(req.ident, board))
return cb(404);
var page = parseInt(params[2], 10);
if (page > 0 && params[2][0] == '0') /* leading zeroes? */
return cb(null, 'redirect', 'page' + page);
var yaku = new db.Yakusoku(board, req.ident);
yaku.get_tag(page);
yaku.once('nomatch', function () {
cb(null, 302, '.');
yaku.disconnect();
});
yaku.once('begin', function (threadCount) {
cb(null, 'ok', {
board: board, page: page, yaku: yaku,
threadCount: threadCount,
});
});
},
function (req, resp) {
/* TEMP */
if (req.ident.suspension)
return render_suspension(req, resp);
var board = this.board;
var nav = page_nav(this.threadCount, this.page, board == 'archive');
resp = write_gzip_head(req, resp, web.noCacheHeaders);
var initScript = make_init_script(req.ident);
render.write_board_head(resp, initScript, board, nav);
var paginationHtml = render.make_pagination_html(nav);
resp.write(paginationHtml);
resp.write('<hr>\n');
var opts = {fullLinks: true, board: board};
render.write_thread_html(this.yaku, req, resp, opts);
var self = this;
this.yaku.once('end', function () {
resp.write(paginationHtml);
render.write_page_end(resp, req.ident, false);
resp.end();
self.finished();
});
this.yaku.once('error', function (err) {
winston.error('page' + self.page + ': ' + err);
resp.end();
self.finished();
});
},
function () {
this.yaku.disconnect();
});
web.resource(/^\/(\w+)\/page(\d+)\/$/, function (req, params, cb) {
if (!caps.temporal_access_check(req.ident, params[1]))
cb(null, 302, '..');
else
cb(null, 'redirect', '../page' + params[2]);
});
web.resource(/^\/(\w+)\/(\d+)$/, function (req, params, cb) {
var board = params[1];
if (!caps.temporal_access_check(req.ident, board))
return cb(null, 302, '.');
if (req.ident.suspension)
return cb(null, 'ok'); /* TEMP */
if (!caps.can_access_board(req.ident, board))
return cb(404);
var num = parseInt(params[2], 10);
if (!num)
return cb(404);
else if (params[2][0] == '0')
return cb(null, 'redirect', '' + num);
var op, json = web.prefers_json(req.headers.accept);
if (board == 'graveyard') {
op = num;
}
else {
op = db.OPs[num];
if (!op)
return cb(404);
if (!json && !db.OP_has_tag(board, op)) {
var tag = db.first_tag_of(op);
if (tag) {
if (!caps.can_access_board(req.ident, tag))
return cb(404);
return redirect_thread(cb, num, op, tag);
}
else {
winston.warn("Orphaned post " + num +
"with tagless OP " + op);
return cb(404);
}
}
if (!json && op != num)
return redirect_thread(cb, num, op);
}
if (!caps.can_access_thread(req.ident, op))
return cb(404);
if (json)
return cb(null, 'ok', {json: true, num: num});
var yaku = new db.Yakusoku(board, req.ident);
var reader = new db.Reader(yaku);
var opts = {redirect: true};
var lastN = detect_last_n(req.query);
if (lastN)
opts.abbrev = lastN + config.ABBREVIATED_REPLIES;
if (caps.can_administrate(req.ident) && 'reported' in req.query)
opts.showDead = true;
reader.get_thread(board, num, opts);
reader.once('nomatch', function () {
cb(404);
yaku.disconnect();
});
reader.once('redirect', function (op, tag) {
redirect_thread(cb, num, op, tag);
yaku.disconnect();
});
reader.once('begin', function (preThread) {
var headers = web.noCacheHeaders;
cb(null, 'ok', {
headers: headers,
board: board, op: op,
subject: preThread.subject,
yaku: yaku, reader: reader,
abbrev: opts.abbrev,
});
});
},
function (req, resp) {
/* TEMP */
if (req.ident.suspension)
return render_suspension(req, resp);
if (this.json)
return write_json_post(req, resp, this.num);
var board = this.board, op = this.op;
resp = write_gzip_head(req, resp, this.headers);
var initScript = make_init_script(req.ident);
render.write_thread_head(resp, initScript, board, op, {
subject: this.subject,
abbrev: this.abbrev,
});
var opts = {fullPosts: true, board: board, loadAllPostsLink: true};
render.write_thread_html(this.reader, req, resp, opts);
var self = this;
this.reader.once('end', function () {
render.write_page_end(resp, req.ident, true);
resp.end();
self.finished();
});
function on_err(err) {
winston.error('thread '+num+':', err);
resp.end();
self.finished();
}
this.reader.once('error', on_err);
this.yaku.once('error', on_err);
},
function () {
this.yaku.disconnect();
});
function write_json_post(req, resp, num) {
var json = {TODO: true};
var cache = json.editing ? 'no-cache' : 'private, max-age=86400';
resp = write_gzip_head(req, resp, {
'Content-Type': 'application/json',
'Cache-Control': cache,
});
resp.end(JSON.stringify(json));
}
function detect_last_n(query) {
for (var k in query) {
var m = /^last(\d+)$/.exec(k);
if (m) {
var n = parseInt(m[1], 10);
if (common.reasonable_last_n(n))
return n;
}
}
return 0;
}
web.resource(/^\/(\w+)\/(\d+)\/$/, function (req, params, cb) {
if (!caps.temporal_access_check(req.ident, params[1]))
cb(null, 302, '..');
else
cb(null, 'redirect', '../' + params[2]);
});
web.resource(/^\/outbound\/(g|iqdb)\/([\w+\/]{22}\.jpg)$/,
function (req, params, cb) {
var thumb = imager.config.MEDIA_URL + 'vint/' + params[2];
// attempt to make protocol more absolute
var u = urlParse(thumb, false, true);
if (!u.protocol) {
u.protocol = 'http:';
thumb = u.format();
}
var service = params[1] == 'iqdb' ? 'http://iqdb.org/?url='
: 'https://www.google.com/searchbyimage?image_url=';
var dest = service + encodeURIComponent(thumb);
cb(null, 303.1, dest);
});
web.resource(/^\/outbound\/hash\/([\w+\/]{22})$/, function (req, params, cb) {
var dest = 'http://archive.foolz.us/_/search/image/' + escape(params[1]);
cb(null, 303.1, dest);
});
web.resource(/^\/outbound\/a\/(\d{0,10})$/, function (req, params, cb) {
var thread = parseInt(params[1], 10);
var url = 'https://boards.4chan.org/a/';
if (thread)
url += 'thread/' + thread;
cb(null, 303.1, url);
});
web.resource(/^\/outbound\/sysint\/(\d{0,10})$/, function (req, params, cb) {
var thread = parseInt(params[1], 10);
var url = 'https://flanchan.moe/sysint/';
if (thread)
url += thread;
cb(null, 303.1, url);
});
/* TODO: this is never hit in master anyway
web.resource(/^\/outbound\/rurus\/(\d{0,10})$/, function (req, params, cb) {
var thread = parseInt(params[1], 10);
var url = '/faq.html';
if (thread)
url += thread;
cb(null, 303.1, url);
});
*/
web.resource(/^\/outbound\/nap\/(\d{0,10})$/, function (req, params, cb) {
var thread = parseInt(params[1], 10);
var url = 'https://nineball.party/nap/';
if (thread)
url += thread;
cb(null, 303.1, url);
});
function make_init_script(ident) {
var secretKey = STATE.hot.connTokenSecretKey;
if (!ident || !secretKey)
return '';
var country = ident.country || 'x';
var payload = JSON.stringify({
ip: ident.ip,
cc: country,
ts: Date.now(),
});
// encrypt payload as 'ctoken'
var iv = crypto.randomBytes(12);
var cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv);
var crypted = cipher.update(payload, 'utf8', 'hex');
crypted += cipher.final('hex');
var authTag = cipher.getAuthTag()
if (authTag.length != 16) throw 'auth tag of unexpected length';
var combined = iv.toString('hex') + authTag.toString('hex') + crypted;
return '\t<script>var ctoken = ' + etc.json_paranoid(combined) + ';</script>\n';
}
function decrypt_ctoken(ctoken) {
var secretKey = STATE.hot.connTokenSecretKey;
if (!secretKey)
return null;
if (ctoken.length < 56) {
winston.warn('ctoken too short');
return null;
}
var iv = new Buffer(ctoken.slice(0, 24), 'hex');
if (iv.length != 12) {
winston.warn('iv not hex');
return null;
}
var authTag = new Buffer(ctoken.slice(24, 56), 'hex');
if (authTag.length != 16) {
winston.warn('authTag not hex');
return null;
}
try {
var decipher = crypto.createDecipheriv('aes-256-gcm', secretKey, iv);
decipher.setAuthTag(authTag);
var plain = decipher.update(ctoken.slice(56), 'hex', 'utf8');
plain += decipher.final('utf8');
return JSON.parse(plain);
}
catch (e) {
winston.warn(e);
}
return null;
}
var TWEET_CACHE = {};
var TWEET_CACHE_LEN = 0;
function expire_tweet(key) {
if (TWEET_CACHE[key]) {
delete TWEET_CACHE[key];
TWEET_CACHE_LEN--;
}
}
web.resource(/^\/outbound\/tweet\/(\w{1,15}\/status\/\d{4,20})$/,
function (req, params, cb) {
var url = 'https://twitter.com/' + params[1];
if (/^\d+$/.test(req.query.s))
url += '?s=' + req.query.s;
var theme = req.query.theme == 'dark' ? 'dark' : 'light';
var params = {
url: url,
omit_script: true,
theme: theme,
};
var key = 'tw:'+url;
if (TWEET_CACHE[key])
return cb(null, 'ok', TWEET_CACHE[key]);
request.get({
uri: 'https://publish.twitter.com/oembed',
qs: params,
json: true,
}, function (err, twResp, json) {
if (err)
return cb(err);
var code = twResp.statusCode;
if (code < 200 || code >= 300) {
if (code == 404)
cb(404);
else
cb('twitter returned ' + code);
return;
}
if (!json.html)
return cb('unexpected tweet form');
if (!TWEET_CACHE[key] && TWEET_CACHE_LEN < 50) {
TWEET_CACHE[key] = {json: json};
TWEET_CACHE_LEN++;
setTimeout(expire_tweet.bind(null, key), 600*1000);
}
cb(null, 'ok', {json: json});
});
}, function (req, resp) {
resp = write_gzip_head(req, resp, {
'Content-Type': 'application/json',
'Cache-Control': config.DEBUG ? 'no-cache' : 'public, max-age=600',
});
resp.end(JSON.stringify(this.json));
});
web.route_get_auth(/^\/dead\/(src|thumb|mid)\/(\w+\.\w{3})$/,
function (req, resp, params) {
if (!caps.can_administrate(req.ident))
return web.render_404(resp);
imager.send_dead_image(params[1], params[2], resp);
});
/* Must be prepared to receive callback instantly */
function valid_links(frag, state, ident, callback) {
var links = {};
var onee = new common.OneeSama(function (num) {
var op = db.OPs[num];
if (op && caps.can_access_thread(ident, op))
links[num] = db.OPs[num];
});
onee.callback = function (frag) {};
onee.state = state;
onee.fragment(frag);
callback(null, _.isEmpty(links) ? null : links);
}
var insertSpec = [{
frag: 'opt string',
image: 'opt string',
nonce: 'id',
op: 'opt id',
name: 'opt string',
email: 'opt string',
auth: 'opt string',
subject: 'opt string',
flavor: 'opt string',
}];
dispatcher[common.INSERT_POST] = function (msg, client) {
if (!check(insertSpec, msg))
return false;
msg = msg[0];
if (client.post)
return update_post(msg.frag, client);
if (!caps.can_access_board(client.ident, client.board))
return false;
var frag = msg.frag;
if (frag && /^\s*$/g.test(frag))
return false;
if (!frag && !msg.image)
return false;
if (config.DEBUG)
debug_command(client, frag);
allocate_post(msg, client, function (err) {
if (err)
client.kotowaru(Muggle("Allocation failure.", err));
});
return true;
}
function inactive_board_check(client) {
if (caps.can_administrate(client.ident))
return true;
return ['graveyard', 'archive'].indexOf(client.board) == -1;
}
function allocate_post(msg, client, callback) {
if (client.post)
return callback(Muggle("Already have a post."));
if (!inactive_board_check(client))
return callback(Muggle("Can't post here."));
var post = {time: Date.now(), nonce: msg.nonce};
var body = '';
var ip = client.ident.ip;
var extra = {ip: ip, board: client.board};
var image_alloc;
if (msg.image) {
if (!/^\d+$/.test(msg.image))
return callback(Muggle('Expired image token.'));
image_alloc = msg.image;
}
if (msg.frag) {
if (/^\s*$/g.test(msg.frag))
return callback(Muggle('Bad post body.'));
if (msg.frag.length > common.MAX_POST_CHARS)
return callback(Muggle('Post is too long.'));
body = msg.frag.replace(config.EXCLUDE_REGEXP, '');
}
if (msg.op) {
if (db.OPs[msg.op] != msg.op)
return callback(Muggle('Thread does not exist.'));
if (!db.OP_has_tag(extra.board, msg.op))
return callback(Muggle('Thread does not exist.'));
post.op = msg.op;
}
else {
if (!image_alloc)
return callback(Muggle('Image missing.'));
var subject = (msg.subject || '').trim();
subject = subject.replace(config.EXCLUDE_REGEXP, '');
subject = subject.replace(/[「」]/g, '');
subject = subject.slice(0, config.SUBJECT_MAX_LENGTH);
if (subject)
post.subject = subject;
}
/* TODO: Check against client.watching? */
if (msg.name) {
var parsed = common.parse_name(msg.name);
post.name = parsed[0];
var spec = STATE.hot.SPECIAL_TRIPCODES;
if (spec && parsed[1] && parsed[1] in spec) {
post.trip = spec[parsed[1]];
}
else if (parsed[1] || parsed[2]) {
var trip = tripcode.hash(parsed[1], parsed[2]);
if (trip)
post.trip = trip;
}
}
if (msg.email) {
post.email = msg.email.trim().substr(0, 320);
if (common.is_noko(post.email))
delete post.email;
}
if (msg.flavor && /^\w+$/.test(msg.flavor)) {
if (msg.flavor == 'floop')
post.flavor = 'floop';
}
post.state = common.initial_state();
if ('auth' in msg) {
if (!msg.auth || !client.ident
|| msg.auth !== client.ident.auth)
return callback(Muggle('Bad auth.'));
post.auth = msg.auth;
}
if (post.op)
client.db.check_thread_locked(post.op, checked);
else
client.db.check_throttle(ip, checked);
function checked(err) {
if (err)
return callback(err);
client.db.reserve_post(post.op, ip, got_reservation);
}
function got_reservation(err, num) {
if (err)
return callback(err);
if (!client.synced)
return callback(Muggle('Dropped; post aborted.'));
if (client.post)
return callback(Muggle('Already have a post.'));
if (body.length && is_game_board(client.board))
amusement.roll_dice(body, post, extra);
client.post = post;
post.num = num;
var supplements = {
links: valid_links.bind(null, body, post.state,
client.ident),
};
if (image_alloc)
supplements.image = imager.obtain_image_alloc.bind(
null, image_alloc);
async.parallel(supplements, got_supplements);
}
function got_supplements(err, rs) {
if (err) {
if (client.post === post)
client.post = null;
return callback(Muggle("Attachment error.", err));
}
if (!client.synced)
return callback(Muggle('Dropped; post aborted.'));
post.links = rs.links;
if (rs.image)
extra.image_alloc = rs.image;
client.db.insert_post(post, body, extra, inserted);
}
function inserted(err) {
if (err) {
if (client.post === post)
client.post = null;
return callback(Muggle("Couldn't allocate post.",err));
}
post.body = body;
callback(null);
}
return true;
}
function update_post(frag, client) {
if (typeof frag != 'string')
return false;
if (config.DEBUG)
debug_command(client, frag);
frag = frag.replace(config.EXCLUDE_REGEXP, '');
var post = client.post;
if (!post)
return false;
var limit = common.MAX_POST_CHARS;
if (frag.length > limit || post.length >= limit)
return false;
var combined = post.length + frag.length;
if (combined > limit)
frag = frag.substr(0, combined - limit);
var extra = {ip: client.ident.ip};
if (is_game_board(client.board))
amusement.roll_dice(frag, post, extra);
post.body += frag;
/* imporant: broadcast prior state */
var old_state = post.state.slice();
valid_links(frag, post.state, client.ident, function (err, links) {
if (err)
links = null; /* oh well */
if (links) {
if (!post.links)
post.links = {};
var new_links = {};
for (var k in links) {
var link = links[k];
if (post.links[k] != link) {
post.links[k] = link;
new_links[k] = link;
}
}
extra.links = links;
extra.new_links = new_links;
}
client.db.append_post(post, frag, old_state, extra,
function (err) {
if (err)
client.kotowaru(Muggle("Couldn't add text.",
err));
});
});
return true;
}
dispatcher[common.UPDATE_POST] = update_post;
function debug_command(client, frag) {
if (!frag)
return;
if (/\bfail\b/.test(frag))
client.kotowaru(Muggle("Failure requested."));
else if (/\bclose\b/.test(frag))
client.socket.close();
}
dispatcher[common.FINISH_POST] = function (msg, client) {
if (!check([], msg))
return false;
if (!client.post)
return true; /* whatever */
client.finish_post(function (err) {
if (err)
client.kotowaru(Muggle("Couldn't finish post.", err));
});
return true;
}
dispatcher[common.DELETE_POSTS] = caps.mod_handler(function (nums, client) {
if (!inactive_board_check(client))
return client.kotowaru(Muggle("Couldn't delete."));
/* Omit to-be-deleted posts that are inside to-be-deleted threads */
var ops = {}, OPs = db.OPs;
nums.forEach(function (num) {
if (num == OPs[num])
ops[num] = 1;
});
nums = nums.filter(function (num) {
var op = OPs[num];
return op == num || !(OPs[num] in ops);
});
client.db.remove_posts(nums, function (err, dels) {
if (err)
client.kotowaru(Muggle("Couldn't delete.", err));
});
});
dispatcher[common.LOCK_THREAD] = caps.mod_handler(function (nums, client) {
if (!inactive_board_check(client))
return client.kotowaru(Muggle("Couldn't (un)lock thread."));
nums = nums.filter(function (op) { return db.OPs[op] == op; });
async.forEach(nums, client.db.toggle_thread_lock.bind(client.db),
function (err) {
if (err)
client.kotowaru(Muggle(
"Couldn't (un)lock thread.", err));
});
});
dispatcher[common.DELETE_IMAGES] = caps.mod_handler(function (nums, client) {
if (!inactive_board_check(client))
return client.kotowaru(Muggle("Couldn't delete images."));
client.db.remove_images(nums, function (err, dels) {
if (err)
client.kotowaru(Muggle("Couldn't delete images.",err));
});
});
dispatcher[common.INSERT_IMAGE] = function (msg, client) {
if (!check(['string'], msg))
return false;
var alloc = msg[0];
if (!client.post || client.post.image)
return false;
imager.obtain_image_alloc(alloc, function (err, alloc) {
if (err)
return client.kotowaru(Muggle("Image lost.", err));
if (!client.post || client.post.image)
return;
client.db.add_image(client.post, alloc, client.ident.ip,
function (err) {
if (err)
client.kotowaru(Muggle(
"Image insertion problem.", err));
});
});
return true;
};
dispatcher[common.SPOILER_IMAGES] = caps.mod_handler(function (nums, client) {
if (!inactive_board_check(client))
return client.kotowaru(Muggle("Couldn't spoiler images."));
client.db.force_image_spoilers(nums, function (err) {
if (err)
client.kotowaru(Muggle("Couldn't spoiler images.",
err));
});
});
dispatcher[common.EXECUTE_JS] = function (msg, client) {
if (!caps.can_administrate(client.ident))
return false;
if (!check(['id'], msg))
return false;
var op = msg[0];
client.db.set_fun_thread(op, function (err) {
if (err)
client.kotowaru(err);
});
return true;
};
function is_game_board(board) {
return config.GAME_BOARDS.indexOf(board) >= 0;
}
function render_suspension(req, resp) {
setTimeout(function () {
var ban = req.ident.suspension, tmpl = RES.suspensionTmpl;
resp.writeHead(200, web.noCacheHeaders);
resp.write(tmpl[0]);
resp.write(escape(ban.why || ''));
resp.write(tmpl[1]);
resp.write(escape(ban.until || ''));
resp.write(tmpl[2]);
resp.write(escape(STATE.hot.EMAIL || '<missing>'));
resp.end(tmpl[3]);
}, 2000);
}
function get_sockjs_script_sync() {
var src = fs.readFileSync('tmpl/index.html', 'UTF-8');
return src.match(/sockjs-[\d.]+(?:\.min)?\.js/)[0];
}
function sockjs_log(sev, message) {
if (message.length > 80)
message = message.slice(0, 60) + '[\u2026]' + message.slice(message.length - 14);
if (sev == 'info')
winston.verbose(message);
else if (sev == 'error')
winston.error(message);
}
if (config.DEBUG) {
winston.remove(winston.transports.Console);
winston.add(winston.transports.Console, {level: 'verbose'});
}
else {
winston.add(winston.transports.File, {level: 'warn', filename: 'error.log'});
}
function start_server() {
var is_unix_socket = (typeof config.LISTEN_PORT == 'string');
if (is_unix_socket) {
try { fs.unlinkSync(config.LISTEN_PORT); } catch (e) {}
}
web.server.listen(config.LISTEN_PORT, config.LISTEN_HOST);
if (is_unix_socket) {
fs.chmodSync(config.LISTEN_PORT, '777'); // TEMP
}
var sockjsPath = 'js/' + get_sockjs_script_sync();
var sockOpts = {
sockjs_url: imager.config.MEDIA_URL + sockjsPath,
prefix: config.SOCKET_PATH,
jsessionid: false,
log: sockjs_log,
websocket: config.USE_WEBSOCKETS,
};
var sockJs = require('sockjs').createServer(sockOpts);
web.server.on('upgrade', function (req, resp) {
resp.end();
});
sockJs.installHandlers(web.server);
sockJs.on('connection', function (socket) {
var ip = socket.remoteAddress;
var country;
if (config.TRUST_X_FORWARDED_FOR) {
var ff = web.parse_forwarded_for(
socket.headers['x-forwarded-for']);
if (ff)
ip = ff;
}
if (!ip) {
winston.warn('no ip from ' + socket);
socket.close();
return;
}
// parse ctoken
var url = urlParse(socket.url, true);
if (url.query && url.query.ctoken) {
var token = decrypt_ctoken(url.query.ctoken);
if (token) {
if (token.ts + 24*60*60*1000 < Date.now()) {
// token expired, ask for a new one?
winston.warn('ctoken: expired');
}
if (ip != token.ip)
winston.info('ctoken: ' + ip + ' != ' + token.ip);
country = token.cc;
}
else {
winston.log('ctoken: invalid from ' + ip);
}
} else {
winston.warn('ctoken: MISSING from ' + ip);
}
var client = new okyaku.Okyaku(socket, ip, country);
socket.on('data', client.on_message.bind(client));
socket.on('close', client.on_close.bind(client));
});
process.on('SIGHUP', hot_reloader);
db.on_pub('reloadHot', hot_reloader);
if (config.DAEMON) {
var cfg = config.DAEMON;
var daemon = require('daemon');
var pid = daemon.start(process.stdout.fd, process.stderr.fd);
var lock = config.PID_FILE;
daemon.lock(lock);
winston.remove(winston.transports.Console);
return;
}
process.nextTick(non_daemon_pid_setup);
winston.info('Listening on '
+ (config.LISTEN_HOST || '')
+ (is_unix_socket ? '' : ':')
+ (config.LISTEN_PORT + '.'));
}
function hot_reloader() {
STATE.reload_hot_resources(function (err) {
if (err) {
winston.error("Error trying to reload:");
winston.error(err);
return;
}
okyaku.scan_client_caps();
winston.info('Reloaded initial state.');
});
}
function non_daemon_pid_setup() {
var pidFile = config.PID_FILE;
fs.writeFile(pidFile, process.pid+'\n', function (err) {
if (err)
return winston.warn("Couldn't write pid: " + err);
process.once('SIGINT', delete_pid);
process.once('SIGTERM', delete_pid);
winston.info('PID ' + process.pid + ' written in ' + pidFile);
});
function delete_pid() {
try {
fs.unlinkSync(pidFile);
}
catch (e) { }
process.exit(1);
}
}
if (require.main == module) {
if (!process.getuid())
throw new Error("Refusing to run as root.");
if (!tripcode.setSalt(config.SECURE_SALT))
throw "Bad SECURE_SALT";
async.series([
imager.make_media_dirs,
setup_imager_relay,
STATE.reload_hot_resources,
db.track_OPs,
], function (err) {
if (err)
throw err;
var yaku = new db.Yakusoku(null, db.UPKEEP_IDENT);
var onegai;
var writes = [];
if (!config.READ_ONLY) {
writes.push(yaku.finish_all.bind(yaku));
if (!imager.is_standalone()) {
onegai = new imager.Onegai;
writes.push(onegai.delete_temporaries.bind(
onegai));
}
}
async.series(writes, function (err) {
if (err)
throw err;
yaku.disconnect();
if (onegai)
onegai.disconnect();
process.nextTick(start_server);
});
});
}