pdfdraw/server/server.js

1025 lines
26 KiB
JavaScript

/**
* @copyright Copyright (C) 2018, struktur AG
*
* @author Joachim Bauch <mail@joachim-bauch.de>
*
* @license AGPL-3.0
*
* 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, either version 3 of the
* License, or (at your option) any later version.
*
* 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 <https://www.gnu.org/licenses/>.
*/
"use strict";
var process = require('process');
var config;
try {
config = require('./config');
} catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
console.log('Could not load "config.js":', e.code);
console.log('Please make sure to copy "config.js.in" to "config.js" and adjust to your environment.');
process.exit(1);
}
var port = config.port || 8080;
var secret = config.secret || '';
var child_process = require('child_process');
var fs = require('fs');
var path = require('path');
var https = require('https');
var http = require('http');
var jwt = require('jsonwebtoken');
var os = require('os');
var socketio = require('socket.io');
var querystring = require('querystring');
var url = require('url');
if (config.use_auto_ecdh_curve) {
// See https://github.com/nodejs/node/issues/21513#issuecomment-399790415
require("tls").DEFAULT_ECDH_CURVE = "auto";
}
if (config.allow_invalid_certificates) {
console.log('WARNING: Invalid certificates are allowed!');
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0;
}
var CMD_PDFTK = config.cmd_pdftk || 'pdftk';
console.log('Using', CMD_PDFTK, 'to run as pdftk');
var CMD_SVG2PDF = config.cmd_svg2pdf || 'svg2pdf';
console.log('Using', CMD_SVG2PDF, 'to run as svg2pdf');
var PERMISSION_CREATE = 4;
var PERMISSION_READ = 1;
var PERMISSION_UPDATE = 2;
var PERMISSION_DELETE = 8;
var PERMISSION_SHARE = 16;
var PERMISSION_ALL = 31;
function isEmpty(obj) {
return Object.keys(obj).length === 0 && obj.constructor === Object;
}
function parse_url(s) {
if (url.hasOwnProperty('URL')) {
return new url.URL(s);
} else {
return url.parse(s);
}
}
function performOcsRequest(token, request_url, body, method) {
return new Promise(function(resolve, reject) {
var u = parse_url(request_url);
var headers = {
"Accept": "application/json",
"Authorization": "Bearer " + token,
"OCS-APIRequest": "true"
};
var options = {
"hostname": u.hostname,
"port": u.port,
"path": u.pathname,
"headers": headers
};
if (body) {
options.method = method ? method : "POST";
if (typeof(body) !== "string") {
body = querystring.stringify(body);
}
headers["Content-Type"] = "application/x-www-form-urlencoded";
// "Transfer-Encoding: chunked" doesn't seem to work.
headers["Content-Length"] = body.length;
} else {
options.method = method ? method : "GET";
}
var req = https.request(options, function(response) {
var data = [];
response.on('data', function(chunk) {
data.push(chunk);
});
response.on('end', function() {
if (data.length) {
data = Buffer.concat(data).toString("utf-8");
}
var ct = response.headers['content-type'];
if (ct && ct.indexOf("application/json") === 0) {
data = JSON.parse(data);
}
if (response.statusCode !== 200) {
return reject({
"code": response.statusCode,
"data": data,
"headers": response.headers
});
}
if (!data || typeof(data) === "string" || !data.hasOwnProperty("ocs") ||
!data.ocs.hasOwnProperty("meta") || !data.ocs.hasOwnProperty("data")) {
return reject({
"code": response.statusCode,
"data": data,
"headers": response.headers,
"msgid": "invalid_response"
});
}
var meta = data.ocs.meta;
if (meta.status !== "ok" || meta.statuscode !== 200) {
return reject({
"code": meta.statusCode,
"data": data,
"headers": response.headers
});
}
resolve(data.ocs.data);
});
});
req.on("error", function(error) {
reject({
"error": error
});
});
if (body) {
req.write(body);
}
req.end();
});
}
var Room = function(io, id, base_url) {
this.io = io;
this.room_io = io.to(id);
this.id = id;
this.base_url = base_url;
this.ocs_url = base_url + '/ocs/v2.php/apps/pdfdraw';
this.users = {};
this.items = [];
this.currentPage = null;
this.loadItems();
};
Room.prototype.isEmpty = function() {
return isEmpty(this.users);
};
Room.prototype.createToken = function() {
var token = jwt.sign({
iss: 'backend',
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
file: this.id,
}, secret, {'algorithm': 'HS256'});
return token;
};
Room.prototype.loadItems = function() {
var request_url = this.ocs_url + "/api/v1/item/" + this.id;
var token = this.createToken();
performOcsRequest(token, request_url).then(function(items) {
console.log("Received items", items);
for (var i = 0; i < items.length; i++) {
var item = items[i];
this._addItem(item.name, item.page, item.data);
}
this.sendItems();
}.bind(this), function(error) {
console.log("Error receiving items", error);
}.bind(this));
};
Room.prototype._storeItem = function(page, name, data) {
var request_url = this.ocs_url + "/api/v1/item/" + this.id + "/" + page + "/" + encodeURIComponent(name);
var body = {
"data": data
};
var token = this.createToken();
performOcsRequest(token, request_url, body).then(function() {
console.log("Saved item", name);
}.bind(this), function(error) {
console.log("Error saving item", name, error);
}.bind(this));
};
Room.prototype._removeItem = function(page, name) {
var request_url = this.ocs_url + "/api/v1/item/" + this.id + "/" + page + "/" + encodeURIComponent(name);
var token = this.createToken();
performOcsRequest(token, request_url, null, "DELETE").then(function() {
console.log("Removed item", name);
}.bind(this), function(error) {
console.log("Error removing item", name, error);
}.bind(this));
};
Room.prototype.send = function(socket, type, message) {
socket.to(this.id).emit(type, message);
};
Room.prototype.sendMessage = function(socket, message) {
this.send(socket, 'message', message);
};
Room.prototype._addItem = function(name, page, data) {
var items;
if (!this.items.hasOwnProperty(page)) {
items = this.items[page] = {};
} else {
items = this.items[page];
}
items[name] = data;
};
Room.prototype.addItem = function(socket, name, page, data) {
if (!this.userMayModify(socket)) {
return false;
}
this._addItem(name, page, data);
this._storeItem(page, name, data);
return true;
};
Room.prototype.removeItem = function(socket, name, page) {
if (!this.userMayModify(socket)) {
return false;
}
var items;
if (!this.items.hasOwnProperty(page)) {
return true;
}
items = this.items[page];
delete items[name];
this._removeItem(page, name);
if (isEmpty(items)) {
delete this.items[page];
}
return true;
};
Room.prototype.sendItem = function(socket, name, page, data) {
var message = {
'type': 'item',
'item': {
'page': page,
'name': name,
'data': data
}
};
socket.emit("message", message);
};
Room.prototype.sendItems = function(socket) {
for (var page in this.items) {
if (!this.items.hasOwnProperty(page)) {
continue;
}
var items = this.items[page];
for (var name in items) {
if (!items.hasOwnProperty(name)) {
continue;
}
if (!socket) {
for (var userid in this.users) {
if (!this.users.hasOwnProperty(userid)) {
continue;
}
this.sendItem(this.io.to(userid), name, page, items[name]);
}
} else {
this.sendItem(socket, name, page, items[name]);
}
}
}
};
Room.prototype.handleControl = function(socket, message) {
if (!this.userMayModify(socket)) {
return false;
}
var control = message.control;
if (!control || !control.type) {
console.log('ignore invalid control message', message);
return false;
}
switch (control.type) {
case 'page':
var page = control.page;
console.log('room switched page', this.id, page);
this.currentPage = page;
break;
default:
console.log('ignore unsupported control message', message);
return false;
}
return true;
};
Room.prototype.addUser = function(socket, data) {
data.userid = socket.id;
console.log('user joined room', this.id, data);
this.send(socket, 'user.joined', [data]);
this.users[socket.id] = data;
var all_users = [];
for (var userid in this.users) {
if (!this.users.hasOwnProperty(userid)) {
continue;
}
all_users.push(this.users[userid]);
}
socket.emit('user.joined', all_users);
this.sendItems(socket);
if (this.currentPage) {
socket.emit("message", {
'type': 'control',
'control': {
'type': 'page',
'page': this.currentPage
}
});
}
};
Room.prototype.removeUser = function(socket) {
if (!this.users.hasOwnProperty(socket.id)) {
return;
}
var data = this.users[socket.id];
console.log('user left room', this.id, data);
delete this.users[socket.id];
this.send(socket, 'user.left', data);
};
Room.prototype.userMayModify = function(socket) {
if (!this.users.hasOwnProperty(socket.id)) {
return false;
}
var userdata = this.users[socket.id];
if (userdata.permissions == null) {
// Old-style user with an old token.
return false;
}
return (userdata.permissions & PERMISSION_UPDATE) === PERMISSION_UPDATE;
};
Room.prototype.downloadFile = function(token) {
return new Promise(function(resolve, reject) {
var u = parse_url(this.base_url + '/apps/pdfdraw/download/' + this.id);
var options = {
"hostname": u.hostname,
"port": u.port,
"method": "GET",
"path": u.pathname,
"headers": {
"Authorization": "Bearer " + token,
}
};
var req = https.request(options, function(response) {
var data = [];
response.on('data', function(chunk) {
data.push(chunk);
});
response.on('end', function() {
if (response.statusCode !== 200) {
return reject({
"code": response.statusCode,
"data": data,
"headers": response.headers
});
}
resolve(Buffer.concat(data));
});
});
req.on("error", function(error) {
reject({
"error": error
});
});
req.end();
}.bind(this));
};
var rooms = {};
function svg2pdf(svg_filename) {
return new Promise(function(resolve, reject) {
var cmd = CMD_SVG2PDF;
console.log("Converting", svg_filename, "to pdf ...");
var options = {};
child_process.execFile(cmd, [svg_filename], options, function(error, stdout, stderr) {
if (error) {
console.log("Converting", svg_filename, "returned an error", error);
if (stderr) {
console.log(CMD_SVG2PDF, "returned an error", error.message, stderr.toString());
} else {
console.log(CMD_SVG2PDF, "returned an error", error.message);
}
reject(error);
return;
}
var pdf_filename = svg_filename.substr(0, svg_filename.length - 4) + ".pdf";
if (!fs.existsSync(pdf_filename)) {
reject(new Error("Converting " + svg_filename + " didn't produce a file"));
return;
}
console.log("Finished converting", svg_filename);
resolve(pdf_filename);
});
});
}
function spawn(cmd, args, options) {
return new Promise((resolve, reject) => {
let stdin;
if (options && options.input) {
stdin = options.input;
delete options.input;
}
const proc = child_process.spawn(cmd, args, options);
if (stdin) {
proc.stdin.write(stdin, (error) => {
proc.stdin.end();
});
}
let data;
let errorData;
proc.on("message", console.log);
proc.stdout.on("data", chunk => {
if (!data) {
data = chunk;
} else {
data = Buffer.concat([data, chunk]);
}
});
proc.stderr.on("data", chunk => {
if (!errorData) {
errorData = chunk;
} else {
errorData = Buffer.concat([errorData, chunk]);
}
});
proc.on("close", function(code) {
resolve({
"status": code,
"stdout": data,
"stderr": errorData,
});
});
proc.on("error", function(err) {
reject(err);
});
});
}
async function combine_pdfs(pages, filename) {
if (!pages.length) {
return false;
}
var cmd = CMD_PDFTK;
console.log("Combining pages ...");
var args = pages.splice(0);
args.push("cat");
args.push("output");
args.push(filename);
var status = await spawn(cmd, args);
console.log("Done", status.status);
if (status.status !== 0) {
if (status.stderr) {
console.log(CMD_PDFTK, "returned an error when combining", status.stderr.toString());
} else {
console.log(CMD_PDFTK, "returned an error when combining", status);
}
return false;
}
// TODO(jojo): Check if file has been created and is non-empty.
return true;
}
async function overlay_pdf(source_pdf, overlay_filename) {
if (!overlay_filename) {
return source_pdf;
}
var cmd = CMD_PDFTK;
console.log("Merging combined with overlay ...");
var args = ["-", "multistamp", overlay_filename, "output", "-"];
var status = await spawn(cmd, args, {
"input": source_pdf,
});
console.log("Done", status.status);
if (status.status !== 0) {
if (status.stderr) {
console.log(CMD_PDFTK, "returned an error when merging", status.stderr.toString());
} else {
console.log(CMD_PDFTK, "returned an error when merging", status);
}
return null;
}
var merged = status.stdout;
if (!merged.length) {
console.log(CMD_PDFTK, "didn't return a PDF when merging");
return null;
}
return merged;
}
async function add_text_annotations(source_pdf, text, tempdir) {
if (!text || !text.length) {
return source_pdf;
}
var cmd = __dirname + "/pdfannotate";
console.log("Adding " + text.length + " text annotations ...");
var text_filename = tempdir + "/text-annotations.json";
fs.writeFileSync(text_filename, JSON.stringify(text));
var args = ["--text", text_filename, "-", "-"];
var status = await spawn(cmd, args, {
"input": source_pdf,
});
console.log("Done", status.status);
if (status.status !== 0) {
if (status.stderr) {
console.log("Adding text annotations returned an error", status.stderr.toString());
} else {
console.log("Adding text annotations returned an error", status);
}
return source_pdf;
}
var annotated = status.stdout;
if (!annotated.length) {
console.log("Adding text annotations didn't return a PDF");
return source_pdf;
}
return annotated;
}
// mktemp code taken from https://github.com/sasaplus1/mktemp/ (MIT license)
/**
* random table string and table length.
*/
var TABLE = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
var TABLE_LEN = TABLE.length;
/**
* generate random string from template.
*
* replace for placeholder "X" in template.
* return template if not has placeholder.
*
* @param {String} template template string.
* @throws {TypeError} if template is not a String.
* @return {String} replaced string.
*/
function generate_randomstring(template) {
var match, i, len, result;
if (typeof template !== 'string') {
throw new TypeError('template must be a String: ' + template);
}
match = template.match(/(X+)[^X]*$/);
// return template if not has placeholder
if (match === null) {
return template;
}
// generate random string
for (result = '', i = 0, len = match[1].length; i < len; ++i) {
result += TABLE[Math.floor(Math.random() * TABLE_LEN)];
}
// concat template and random string
return template.slice(0, match.index) + result +
template.slice(match.index + result.length);
}
/**
* sync version createDir.
*
* @param {String} template template string for dirname.
* @return {String} created dirname.
*/
function createDirSync(template) {
var isExist, dirname;
// FIXME: infinite loop
do {
isExist = false;
dirname = generate_randomstring(template);
try {
fs.mkdirSync(dirname, 448 /*=0700*/);
} catch (e) {
if (e.code === 'EEXIST') {
isExist = true;
} else {
throw e;
}
}
} while (isExist);
return dirname;
}
// From https://stackoverflow.com/a/32197381
var deleteFolderRecursive = function(path) {
if (fs.existsSync(path)) {
fs.readdirSync(path).forEach(function(file, index) {
var curPath = path + "/" + file;
if (fs.lstatSync(curPath).isDirectory()) { // recurse
deleteFolderRecursive(curPath);
} else { // delete file
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(path);
}
};
var runSvg2PdfCombiner = function(tempdir, data) {
return new Promise(function(resolve, reject) {
var promises = [];
var svg_cache = {};
for (var i = 0; i < data.svg.length; i++) {
var svg = data.svg[i];
if (svg_cache.hasOwnProperty(svg)) {
promises.push(svg_cache[svg]);
continue;
}
var svg_filename = tempdir + "/page-" + i + ".svg";
fs.writeFileSync(svg_filename, svg);
var promise = svg2pdf(svg_filename);
svg_cache[svg] = promise;
promises.push(promise);
}
Promise.all(promises)
.then(function(pdfs) {
var combined_filename = tempdir + "/combined.pdf";
combine_pdfs(pdfs, combined_filename)
.then((status) => {
if (!status) {
reject();
return;
}
resolve(combined_filename);
});
})
.catch(function(error) {
reject(error);
});
});
};
function pad(n) {
return n < 10 ? '0' + n : n;
}
function formatTimestamp(d) {
return '' +
d.getFullYear() +
pad(d.getMonth() + 1) +
pad(d.getDate()) + '-' +
pad(d.getHours()) +
pad(d.getMinutes()) +
pad(d.getSeconds());
}
function getDownloadFilename(filename, now) {
var ext = path.extname(filename);
filename = path.basename(filename, ext);
filename = filename.replace('"', '');
filename += '-Annotated-';
filename += formatTimestamp(now);
filename += ext || '.pdf';
return filename;
}
var server = http.createServer(function(request, response) {
if (request.method === "OPTIONS") {
// Pre-flight request
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Expose-Headers": "Content-Disposition"
});
response.end();
return;
}
if (request.method !== "POST") {
response.writeHead(400, {
"Content-Type": "text/plain"
});
response.end('Bad Request');
return;
}
var u = url.parse(request.url);
if (u.pathname.indexOf("/download/") !== 0) {
response.writeHead(404, {
"Content-Type": "text/plain"
});
response.end('Not Found');
return;
}
var room_id = u.pathname.substr(10);
if (!rooms.hasOwnProperty(room_id)) {
console.log("Unknown room", room_id);
response.writeHead(404, {
"Content-Type": "text/plain"
});
response.end('Not Found');
return;
}
var room = rooms[room_id];
var body = null;
request.on("data", function(chunk) {
if (body === null) {
body = chunk;
} else {
body += chunk;
}
});
request.on('end', () => {
if (!body || !body.length) {
console.log("Client sent an empty request");
response.writeHead(400, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Bad Request');
return;
}
var data = JSON.parse(body.toString("utf-8"));
var token = data.token;
var decoded;
try {
decoded = jwt.verify(token, secret, {"algorithms": ['HS256']});
} catch (err) {
console.log("Client sent an invalid token", token);
response.writeHead(401, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Unauthorized');
return;
}
if (room_id !== decoded.file) {
console.log("Client sent a token for a different file", decoded);
response.writeHead(401, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Unauthorized');
return;
}
var downloader = room.downloadFile(token);
var tempdir = createDirSync(os.tmpdir() + "/svg2pdf-XXXXXX");
var combiner = runSvg2PdfCombiner(tempdir, data);
Promise.all([downloader, combiner]).then(function(results) {
var source_pdf = results[0];
var combined_filename = results[1];
if (!source_pdf.length) {
deleteFolderRecursive(tempdir);
console.log("Empty file received", room.id, token);
response.writeHead(500, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Internal Server Error');
return;
}
overlay_pdf(source_pdf, combined_filename)
.then((merged) => {
add_text_annotations(merged, data.text, tempdir)
.then((merged) => {
deleteFolderRecursive(tempdir);
if (!merged) {
response.writeHead(500, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Internal Server Error');
return;
}
var now = new Date();
var filename = getDownloadFilename(decoded.filename || room_id, now);
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=\"" + filename + "\"",
"Content-Length": merged.length
});
response.end(merged);
});
});
})
.catch(function(error) {
deleteFolderRecursive(tempdir);
if (error) {
console.log("Error", error);
}
response.writeHead(500, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type, Content-Disposition",
"Access-Control-Expose-Headers": "Content-Disposition",
"Content-Type": "text/plain"
});
response.end('Internal Server Error');
});
});
});
var io = socketio(server, {
'cors': {
'origin': '*:*',
}
});
io.on('connection', function(socket) {
var query = socket.handshake.query;
console.log('a user connected', query);
var token = query.token;
var decoded;
try {
decoded = jwt.verify(token, secret, {"algorithms": ['HS256']});
} catch(err) {
console.log("Invalid token received", err);
socket.disconnect(true);
return;
}
var baseurl = decoded.iss;
if (!baseurl) {
console.log('token did not contain a issuer', decoded);
socket.disconnect(true);
return;
}
var room_id = decoded.file;
if (!room_id) {
console.log("token did not contain a file id", decoded);
socket.disconnect(true);
return;
}
console.log("Token", decoded);
var room;
socket.on('disconnecting', function(reason) {
if (!room) {
return;
}
room.removeUser(socket);
if (room.isEmpty()) {
console.log("deleting empty room", room.id);
delete rooms[room.id];
}
});
socket.on('disconnect', function(reason) {
console.log('a user disconnected');
});
if (!rooms.hasOwnProperty(room_id)) {
room = rooms[room_id] = new Room(io, room_id, baseurl);
console.log("created room", room_id);
} else {
room = rooms[room_id];
console.log('joined room', room_id);
}
socket.on('message', function(message) {
message.userid = socket.id;
var page, name;
if (message.type === "item") {
page = message.item.page;
name = message.item.name;
var data = message.item.data;
if (!page || !name || !data) {
// Ignore invalid entries.
return;
}
try {
JSON.parse(data);
} catch (e) {
// Ignore items with invalid data.
return;
}
if (!room.addItem(socket, name, page, data)) {
return;
}
} else if (message.type === "delete") {
page = message.delete.page;
name = message.delete.name;
if (!page || !name) {
// Ignore invalid entries.
return;
}
if (!room.removeItem(socket, name, page)) {
return;
}
} else if (message.type === "control") {
if (!room.handleControl(socket, message)) {
return;
}
}
room.sendMessage(socket, message);
});
socket.join(room_id);
var displayname = decoded.displayname || null;
var permissions = decoded.permissions || null;
room.addUser(socket, {
'displayname': displayname,
'permissions': permissions
});
});
server.listen(port, function() {
console.log('listening on *:' + port);
});