likeopera-backend/SyncerWeb.js

337 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

const gen = require('gen-thread');
const MailParser = require('mailparser').MailParser;
const htmlawed = require('htmlawed');
const http = require('http');
const socket_io = require('socket.io');
const express = require('express');
const express_session = require('express-session');
const bodyparser = require('body-parser');
const multer = require('multer');
const css = require('css');
const MAX_FETCH = 100;
module.exports = SyncerWeb;
function SyncerWeb(syncer, pg, cfg)
{
this.syncer = syncer;
this.pg = pg;
this.cfg = cfg;
this.app = express();
this.http = http.Server(this.app);
this.io = socket_io(this.http);
this.app.use(bodyparser.urlencoded({ extended: false }));
this.app.use(express_session({
secret: this.cfg.sessionSecret || '1083581xm1l3s1l39k',
resave: false,
saveUninitialized: false
}));
this.app.get('/auth', this.get_auth);
this.app.post('/auth', this.post_auth);
this.app.get('/folders', genRequest(this.get_folders.bind(this)));
this.app.get('/groups', genRequest(this.get_groups.bind(this)));
this.app.get('/messages', genRequest(this.get_messages.bind(this)));
this.app.get('/message', genRequest(this.get_message.bind(this)));
this.app.post('/sync', genRequest(this.post_sync.bind(this)));
this.syncer.events.on('sync', this.syncer_sync.bind(this));
}
SyncerWeb.prototype.listen = function(port)
{
this.http.listen(port);
}
SyncerWeb.prototype.get_auth = function(req, res)
{
return res.type('html').send(
'<form action="/auth" method="post"><input name="login" />'+
' <input name="password" type="password" /> <input type="submit" /></form>'
);
}
SyncerWeb.prototype.post_auth = function(req, res)
{
if (!req.body)
return res.sendStatus(400);
if (req.body.login == this.cfg.login && req.body.password == this.cfg.password)
{
req.session.auth = true;
return res.send({ ok: true });
}
return res.send({ ok: false });
}
SyncerWeb.prototype.get_folders = function*(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
var [ accounts ] = yield this.pg.select(
'id, name, email, settings->\'folders\' folderMap,'+
' (select count(*) from messages m, folders f where m.folder_id=f.id and f.account_id=a.id and (flags @> array[\'pinned\',\'unread\']::varchar(255)[])) pinned_unread_count'
).from('accounts a').rows(gen.ef());
var [ folders ] = yield this.pg.select(
'id, account_id, name,'+
' (select count(*) from messages m where m.folder_id=f.id) total_count,'+
' (select count(*) from messages m where m.folder_id=f.id and (flags @> array[\'unread\']::varchar(255)[])) unread_count'
).from('folders f').orderBy('account_id, name').rows(gen.ef());
var fh = {};
for (let i = 0; i < folders.length; i++)
{
fh[folders[i].account_id] = fh[folders[i].account_id] || [];
fh[folders[i].account_id].push(folders[i]);
}
for (let i = 0; i < accounts.length; i++)
{
accounts[i].folders = fh[accounts[i].id] || [];
}
return res.send({ accounts: accounts });
}
function ymd(dt)
{
var m = dt.getMonth()+1;
var d = dt.getDate();
return dt.getFullYear()+'-'+(m < 10 ? '0'+m : m)+'-'+(d < 10 ? '0'+d : d);
}
SyncerWeb.prototype.msgSearchCond = function(query)
{
var p = [];
if (query.folderId)
p.push(this.pg.sql.eq('m.folder_id', query.folderId));
else if (query.folderType == 'unread')
p.push(this.pg.sql('(flags @> array[\'unread\']::varchar(255)[])'));
else if (query.folderType == 'pinned')
p.push(this.pg.sql('(flags @> array[\'flagged\']::varchar(255)[])'));
else if (query.folderType == 'inbox')
{
var folders = [];
for (var id in this.syncer.accounts)
{
n = this.syncer.accounts[id].settings.folders.spam;
if (n)
folders.push(this.pg.sql.and({ 'f.name': n, 'f.account_id': id }));
}
p.push(this.pg.sql.not(this.pg.sql.or.apply(this.pg.sql, folders)));
p.push(this.pg.sql('(flags @> array[\'in\']::varchar(255)[])'));
}
else if (query.folderType == 'out')
p.push(this.pg.sql('(flags @> array[\'out\']::varchar(255)[])'));
else if (query.folderType == 'outbox')
{
// TODO это какая-то хитрая метапапка, которая не живёт на IMAP'е?
}
else if (query.folderType == 'drafts' || query.folderType == 'spam' || query.folderType == 'trash')
{
var folders = [];
var n;
for (var id in this.syncer.accounts)
{
n = this.syncer.accounts[id].settings.folders[query.folderType];
if (n)
folders.push(this.pg.sql.and({ 'f.name': n, 'f.account_id': id }));
}
p.push(this.pg.sql.or.apply(this.pg.sql, folders));
}
if (typeof query.search == 'string' && query.search.trim())
p.push(this.pg.sql('messages_fulltext(m) @@ plainto_tsquery($1)', query.search.trim()));
if (query.accountId)
p.push(this.pg.sql.and(p, this.pg.sql.eq('f.account_id', query.accountId)));
return p.length ? this.pg.sql.and.apply(this.pg.sql, p) : null;
}
SyncerWeb.prototype.get_groups = function*(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
return res.sendStatus(401);
var cond = this.msgSearchCond(req.query);
if (!cond)
return res.status(500).send('Need message query parameters');
var intervals = [];
var today, today_ts;
today = new Date(ymd(new Date()));
today_ts = today.getTime();
var week_start = today_ts - ((today.getDay()+6)%7)*86400000;
var prev_week = ymd(new Date(week_start - 86400000*7));
for (var i = 1; i <= 12; i++)
{
var d = today.getFullYear()+'-'+(i < 10 ? '0' : '')+i+'-01';
if (d >= prev_week)
break;
intervals.push({ date: d, name: 'm'+i });
}
intervals.push({ date: prev_week, name: 'pw' });
for (var i = week_start, d = 1; i < today_ts; i += 86400000, d++)
{
intervals.push({ date: ymd(new Date(i)), name: 'd'+d });
}
for (var i = today.getFullYear()-1; i >= 1970; i--)
{
intervals.unshift({ date: i+'-01-01', name: ''+i });
}
intervals.push({ date: ymd(today), name: 't' });
for (var i = 0; i < intervals.length-1; i++)
{
intervals[i].date_end = intervals[i+1].date;
}
intervals[intervals.length-1].date_end = '100000-12-31'; // it's faster than (is null or <)
var [ groups ] = yield this.pg
.select('d.name, d.date, ('+
this.pg.select('count(*)')
.from('messages m')
.innerJoin('folders f', this.pg.sql('f.id=m.folder_id'))
.where(cond)
.where(this.pg.sql('m.time >= d.date::date and m.time < d.date_end::date'))
+') count')
.from(this.pg.sql.values(intervals).as('d').columns())
.orderBy('date desc').rows(gen.ef());
groups = groups.filter(g => g.count > 0);
return res.send({ groups: groups });
}
SyncerWeb.prototype.get_messages = function*(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
return res.sendStatus(401);
var cond = this.msgSearchCond(req.query);
if (!cond)
return res.status(500).send('Need message query parameters');
var limit = req.query.limit || 50;
if (limit > MAX_FETCH)
limit = MAX_FETCH;
var offset = req.query.offset || 0;
var [ msgs ] = yield this.pg.select('m.*').from('messages m')
.innerJoin('folders f', this.pg.sql('f.id=m.folder_id'))
.where(cond).orderBy('time desc').limit(limit).offset(offset)
.rows(gen.ef());
for (var i = 0; i < msgs.length; i++)
{
delete msgs[i].text_index;
}
return res.send({ messages: msgs });
}
SyncerWeb.prototype.get_message = function*(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
return res.sendStatus(401);
var msgId = req.query.msgId;
console.log('fetch message '+msgId);
var [ msg ] = yield this.pg.select('m.*, f.name folder_name, f.account_id')
.from('messages m').join('folders f', this.pg.sql('f.id=m.folder_id'))
.where({ 'm.id': msgId }).rows(gen.ef());
if (!msg.length)
return res.send({ error: 'not-found' });
msg = msg[0];
if (!msg.body_html && !msg.body_text)
{
var srv = yield* this.syncer.imap.getConnection(msg.account_id, msg.folder_name);
var [ upd ] = yield* this.syncer.imap.runFetch(
srv, msg.uid, { bodies: '' },
(messages, state) => this.getBody(messages, msg.folder_id)
);
this.syncer.imap.releaseConnection(msg.account_id);
return res.send({ msg: { ...msg, ...upd } });
}
return res.send({ msg: msg });
}
SyncerWeb.prototype.syncer_sync = function(params)
{
this.io.emit('sync', params);
}
SyncerWeb.prototype.post_sync = function*(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
return res.sendStatus(401);
if (this.syncer.syncInProgress)
return res.send({ error: 'already-running' });
gen.run(this.syncer.syncAll());
return res.send({ status: 'started' });
}
function rewriteCss(ast)
{
var rules = ast.rules || ast.stylesheet && ast.stylesheet.rules;
if (ast.stylesheet && ast.stylesheet.parsingErrors)
{
delete ast.stylesheet.parsingErrors;
}
if (rules)
{
for (var i = 0; i < rules.length; i++)
{
if (rules[i].type == 'document')
{
// prune @document instructions (may spy on current URL)
rules.splice(i--, 1);
}
else if (rules[i].type == 'rule' && (!rules[i].selectors || !rules[i].declarations))
rules.splice(i--, 1);
else
rewriteCss(rules[i]);
}
}
else if (ast.type == 'rule')
{
for (var i = 0; i < ast.selectors.length; i++)
{
// FIXME: Do not hardcode css selector for frontend here
// This will require generating unique substitution string,
// so we may also generate 'blocked images' stubs when we do it.
ast.selectors[i] = '.message-view .text '+ast.selectors[i];
}
}
}
function sanitizeHtml(html)
{
let styles = '';
html = (html||'').replace(/<style[^<>]*>([\s\S]*?)<\/style\s*>/ig, function(m, m1)
{
styles += m1+'\n';
return '';
});
html = html.replace(/^[\s\S]*?<body[^<>]*>([\s\S]*)<\/body>[\s\S]*$/i, '$1');
html = html.replace(/^[\s\S]*?<html[^<>]*>([\s\S]*)<\/html>[\s\S]*$/i, '$1');
if (styles)
{
html = '<style>\n'+styles+'</style>\n'+html;
styles = '';
}
html = htmlawed.sanitize(html||'', { safe: 1, elements: '* +style', keep_bad: 0, comment: 1 });
html = html.replace(/<style[^>]*>([\s\S]*)<\/style\s*>/ig, function(m, m1)
{
var ast = css.parse(m1, { silent: true });
rewriteCss(ast);
return '<style>'+css.stringify(ast)+'</style>';
});
return html;
}
SyncerWeb.prototype.getBody = function*(messages, boxId)
{
for (var i = 0; i < messages.length; i++)
{
let msg = messages[i];
let obj = yield* this.syncer.parseMsg(msg[0].headers);
obj.html = sanitizeHtml(obj.html);
let upd = { body_text: obj.text||'', body_html: obj.html };
upd.body_html_text = obj.html.replace(/<style[^>]*>.*<\/style\s*>|<\/?[^>]*>/g, '');
yield this.pg.update('messages m', upd).where({ folder_id: boxId, uid: msg[0].uid }).run(gen.ef());
if (messages.length == 1)
return [ upd ];
}
}
function genRequest(fn)
{
return (req, res) => gen.run(fn(req, res), null, e => res.status(500).send('Internal Error: '+e.stack));
}