likeopera-backend/SyncerWeb.js

362 lines
12 KiB
JavaScript
Raw Permalink 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 MailParser = require('mailparser').MailParser;
const http = require('http');
const socket_io = require('socket.io');
const express = require('express');
const express_session = require('express-session');
const morgan = require('morgan');
const bodyparser = require('body-parser');
const multer = require('multer');
const rawurlencode = require('rawurlencode');
const SQL = require('./select-builder-pgsql.js');
const MAX_FETCH = 100;
class SyncerWeb
{
constructor(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.enable('trust proxy');
this.app.use(morgan('combined'));
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', (req, res) => this.get_auth(req, res));
this.app.post('/auth', (req, res) => this.post_auth(req, res));
this.app.get('/folders', wrapAsync(this, 'get_folders'));
this.app.get('/groups', wrapAsync(this, 'get_groups'));
this.app.get('/messages', wrapAsync(this, 'get_messages'));
this.app.get('/message', wrapAsync(this, 'get_message'));
this.app.get('/attachment', wrapAsync(this, 'get_attachment'));
this.app.post('/mark', wrapAsync(this, 'post_mark'));
this.app.post('/sync', wrapAsync(this, 'post_sync'));
this.syncer.events.on('sync', (params) => this.syncer_sync(params));
}
listen(port)
{
this.http.listen(port);
}
get_auth(req, res)
{
return res.type('html').send(
'<form action="/auth" method="post"><input name="login" />'+
' <input name="password" type="password" /> <input type="submit" /></form>'
);
}
post_auth(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 });
}
async get_folders(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
const accounts = (await this.pg.query(
'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[\'flagged\',\'unread\'])) pinned_unread_count, '+
' (select count(*) from messages m, folders f'+
' where m.folder_id=f.id and f.account_id=a.id'+
' and (flags @> array[\'flagged\'])) pinned_count'+
' from accounts a'
)).rows;
const folders = (await this.pg.query(
'select id, account_id, name, kind,'+
' (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\'])) unread_count'+
' from folders f order by account_id, name'
)).rows;
let 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 });
}
msgSearchCond(query)
{
let p = {};
if (query.folderId)
{
p['m.folder_id'] = query.folderId;
}
else if (query.folderType == 'pinned')
{
p['(flags @> array[\'flagged\'])'] = [];
}
else if (query.folderType == 'inbox' || query.folderType == 'unread')
{
let folders = Object.keys(this.syncer.accounts)
.map(id => [ id, this.syncer.accounts[id].settings.folders.spam ])
.filter(f => f[1]);
p['(f.account_id, f.name) NOT IN ('+folders.map(f => '(?, ?)').join(', ')+')'] =
[].concat.apply([], folders);
p['f.kind NOT IN (?, ?, ?)'] = [ 'sent', 'drafts', 'trash' ];
if (query.folderType == 'unread')
{
p['(flags @> array[\'unread\'])'] = [];
}
}
else if (query.folderType == 'sent')
{
// Все отправленные
p['f.kind'] = 'sent';
}
else if (query.folderType == 'outbox')
{
// FIXME это "папка" для локально составленных сообщений, не сохранённых в IMAP
}
else if (query.folderType == 'drafts' || query.folderType == 'trash')
{
p['f.kind'] = query.folderType;
}
else if (query.folderType == 'spam')
{
let folders = Object.keys(this.syncer.accounts)
.map(id => [ id, this.syncer.accounts[id].settings.folders[query.folderType] ])
.filter(f => f[1]);
p['(f.account_id, f.name) IN ('+folders.map(f => '(?, ?)').join(', ')+')'] =
[].concat.apply([], folders);
}
if (typeof query.search == 'string' && query.search.trim())
{
p['messages_fulltext(m) @@ plainto_tsquery(?)'] = [ query.search.trim() ];
}
if (query.accountId)
{
p['f.account_id'] = query.accountId;
}
return Object.keys(p).length ? p : null;
}
async get_groups(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
const cond = this.msgSearchCond(req.query);
if (!cond)
{
return res.status(500).send('Need message query parameters');
}
let intervals = [];
let today, today_ts;
today = new Date(ymd(new Date()));
today_ts = today.getTime();
let week_start = today_ts - ((today.getDay()+6)%7)*86400000;
let prev_week = ymd(new Date(week_start - 86400000*7));
for (let i = 1; i <= 12; i++)
{
let 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 (let i = week_start, d = 1; i < today_ts; i += 86400000, d++)
{
intervals.push({ date: ymd(new Date(i)), name: 'd'+d });
}
for (let i = today.getFullYear()-1; i >= 1970; i--)
{
intervals.unshift({ date: i+'-01-01', name: ''+i });
}
intervals.push({ date: ymd(today), name: 't' });
for (let 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 <)
let groups = await SQL.select(
this.pg,
{ d: SQL.values(intervals) },
'd.name, d.date, ('+SQL.select_builder(
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
'count(*) count',
{ ...cond, 'm.time >= d.date::date and m.time < d.date_end::date': [] },
)+') count',
[],
{ order_by: 'date desc' }
);
groups = groups.filter(g => g.count > 0);
return res.send({ groups: groups });
}
async get_messages(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
let cond = this.msgSearchCond(req.query);
if (!cond)
{
return res.status(500).send('Need message query parameters');
}
let limit = req.query.limit || 50;
if (limit > MAX_FETCH)
limit = MAX_FETCH;
let offset = req.query.offset || 0;
let msgs = await SQL.select(
this.pg,
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
'm.*', cond,
{ order_by: 'time desc', limit, offset }
);
for (let i = 0; i < msgs.length; i++)
{
delete msgs[i].text_index;
}
return res.send({ messages: msgs });
}
async get_message(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
let msgId = req.query.msgId;
let msg = await SQL.select(
this.pg,
{ m: 'messages', f: [ 'INNER', 'folders', [ 'f.id=m.folder_id' ] ] },
'm.*, f.name folder_name, f.account_id',
{ 'm.id': msgId }, null, SQL.MS_ROW
);
delete msg.text_index;
if (!msg)
{
return res.send({ error: 'not-found' });
}
if (!msg.body_html && !msg.body_text)
{
let upd = await this.syncer.fetchFullMessage(msg.account_id, msg.folder_id, msg.folder_name, msg.uid);
return res.send({ msg: { ...msg, ...upd[0], props: { ...msg.props, ...upd[0].props } } });
}
return res.send({ msg: msg });
}
async get_attachment(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
let msgId = req.query.msgId;
let sha1 = (req.query.sha1||'').replace(/[^0-9a-fA-F]+/g, '').toLowerCase();
if (!msgId || !sha1)
{
return res.sendStatus(404);
}
let msg = await SQL.select(
this.pg, 'messages', '*', { id: msgId }, null, SQL.MS_ROW
);
let a = ((msg.props||{}).attachments||[]).filter(a => a.sha1 == sha1)[0];
if (!a)
{
return res.sendStatus(404);
}
if (new RegExp(
// HTML may contain cookie-stealing JavaScript and web bugs
'^text/(html|(x-)?javascript)$|^application/x-shellscript$'+
// PHP/Perl/Bash/etc scripts may execute arbitrary code on the server
'|php|perl|python|bash|x-c?sh(e|$)'+
// Client-side hazards on Internet Explorer
'|^text/scriptlet$|^application/x-msdownload$'+
// Windows metafile, client-side vulnerability on some systems
'|^application/x-msmetafile$', 'is'
).exec(a.mimetype))
{
a.mimetype = 'application/octet-stream';
}
res.set('Content-Type', a.mimetype);
res.set('Content-Disposition', 'attachment; filename*=UTF-8\'\''+rawurlencode(a.name));
await new Promise((r, j) => res.sendFile(this.syncer.files_path+'/'+sha1.substr(0, 2)+'/'+sha1.substr(2, 2)+'/'+sha1+'.bin', r));
res.end();
}
async post_mark(req, res)
{
if (this.cfg.login && (!req.session || !req.session.auth))
{
return res.sendStatus(401);
}
if (!req.query.msgId || !req.query.flag || req.query.flag == 'deleted')
{
return res.sendStatus(400);
}
await this.syncer.processFlags(req.query.msgId, req.query.del ? 'del' : 'add', req.query.flag);
return res.send({ ok: true });
}
syncer_sync(params)
{
this.io.emit('sync', params);
}
async post_sync(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' });
}
this.syncer.syncAll().catch(console.error);
return res.send({ status: 'started' });
}
}
function wrapAsync(self, fn)
{
return (req, res) =>
{
self[fn](req, res).catch(e =>
{
console.error(e);
res.status(500).send('Internal Error: '+e.stack);
});
};
}
function ymd(dt)
{
let m = dt.getMonth()+1;
let d = dt.getDate();
return dt.getFullYear()+'-'+(m < 10 ? '0'+m : m)+'-'+(d < 10 ? '0'+d : d);
}
module.exports = SyncerWeb;