345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
const mysql = require('mysql2/promise');
|
|
const exec = require('child_process').exec;
|
|
const fs = require('fs-extra');
|
|
const path = require('path');
|
|
const colors = require('colors');
|
|
const axios = require('axios');
|
|
const yml = require('require-yml');
|
|
|
|
const selectQuery = fs.readFileSync(`sql/selectQueued.sql`).toString();
|
|
const jobDataQuery = fs.readFileSync(`sql/jobData.sql`).toString();
|
|
const jobArgsQuery = fs.readFileSync(`sql/jobArgs.sql`).toString();
|
|
const printingQuery = fs.readFileSync(`sql/updatePrinting.sql`).toString();
|
|
const errorQuery = fs.readFileSync(`sql/updateError.sql`).toString();
|
|
const printedQuery = fs.readFileSync(`sql/updatePrinted.sql`).toString();
|
|
|
|
class PrintServer {
|
|
async start() {
|
|
let conf = yml(path.join(__dirname, 'config.yml'));
|
|
const localConfFile = path.join(__dirname, 'config.local.yml');
|
|
if (fs.existsSync(localConfFile))
|
|
conf = Object.assign({}, conf, yml(localConfFile));
|
|
this.conf = conf;
|
|
this.jobs = [];
|
|
|
|
console.clear();
|
|
const decoration = '△▽'.repeat(10);
|
|
const printnatura = colors.bgBlack.bold(' Print'.white + 'Natura '.green);
|
|
console.log(`${decoration} ${printnatura} ${decoration}`);
|
|
if (this.conf.debug)
|
|
this.serverLog('log', 'Debug mode enabled'.yellow);
|
|
if (this.conf.dryPrint)
|
|
this.serverLog('log', 'Running in dry print mode, documents won\'t be printed'.yellow);
|
|
if (this.conf.keepFile)
|
|
this.serverLog('log', 'Keep file enabled, documents won\'t be deleted from disk'.yellow);
|
|
|
|
await this.init();
|
|
this.rejectionHandler = (err, p) => this.onRejection(err, p);
|
|
process.on('unhandledRejection', this.rejectionHandler);
|
|
|
|
this.serverLog('log', 'Ready to print'.green);
|
|
await this.poll();
|
|
}
|
|
async stop() {
|
|
this.serverLog('log', 'Bye ( ◕ ‿ ◕ )っ'.green);
|
|
process.off('unhandledRejection', this.rejectionHandler);
|
|
await this.end();
|
|
}
|
|
async init() {
|
|
const conf = this.conf;
|
|
const api = this.api = axios.create({
|
|
baseURL: `${conf.salix.url}/api/`
|
|
});
|
|
api.interceptors.request.use(config => {
|
|
if (this.token)
|
|
config.headers['Authorization'] = this.token
|
|
return config;
|
|
});
|
|
await this.getToken();
|
|
|
|
this.pool = await mysql.createPool(conf.db);
|
|
}
|
|
async end() {
|
|
if (this.token) {
|
|
await this.api.post(`Accounts/logout`);
|
|
this.token = null;
|
|
}
|
|
if (this.pollTimeout) {
|
|
clearTimeout(this.pollTimeout);
|
|
this.pollTimeout = null;
|
|
}
|
|
await this.pool.end();
|
|
}
|
|
async getToken() {
|
|
const salix = this.conf.salix;
|
|
let response = await this.api.post(`Accounts/login`, {
|
|
user: salix.user,
|
|
password: salix.password
|
|
});
|
|
this.token = response.data.token;
|
|
}
|
|
serverLog(realm, message) {
|
|
this.log(`Server`, realm, message);
|
|
}
|
|
log(classMsg, realm, message) {
|
|
classMsg = `${classMsg}:`;
|
|
|
|
switch(realm) {
|
|
case 'debug':
|
|
if (this.conf.debug) console.debug(classMsg, message.magenta);
|
|
break;
|
|
case 'error':
|
|
console.error(classMsg, message.red);
|
|
break;
|
|
default:
|
|
if (this.conf.log) console.log(classMsg, message);
|
|
}
|
|
}
|
|
onRejection(err, p) {
|
|
console.debug('unhandledRejection');
|
|
this.errorHandler(err);
|
|
}
|
|
errorHandler(err) {
|
|
if (err.code === 'ETIMEDOUT') {
|
|
if (!this.dbDown) {
|
|
this.dbDown = true;
|
|
this.serverLog('error', `DB connection lost: ${err.message}`);
|
|
}
|
|
} else
|
|
console.error(err);
|
|
}
|
|
async poll() {
|
|
this.pollTimeout = null;
|
|
let delay = this.conf.refreshRate;
|
|
await this.getJobs();
|
|
if (this.dbDown) delay = this.conf.reconnectTimeout * 1000;
|
|
this.pollTimeout = setTimeout(() => this.poll(), delay);
|
|
}
|
|
async getJobs() {
|
|
if (this.polling) return;
|
|
this.polling = true;
|
|
const conf = this.conf;
|
|
|
|
if (this.dbDown) {
|
|
try {
|
|
const conn = await this.pool.getConnection();
|
|
try {
|
|
await conn.ping();
|
|
this.dbDown = false;
|
|
this.serverLog('log', 'DB connection recovered'.green);
|
|
} catch (err) {
|
|
conn.release();
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
if (!this.dbDown) {
|
|
try {
|
|
let nJobs = 0;
|
|
while (this.jobs.length < conf.concurrency) {
|
|
const jobId = await this.getJob();
|
|
if (jobId) {
|
|
nJobs++;
|
|
this.jobs.push(jobId);
|
|
const jobP = this.printJob(jobId);
|
|
// XXX: Workaround for Promise unhandledRejection
|
|
// https://stackoverflow.com/questions/67789309/why-do-i-get-an-unhandled-promise-rejection-with-await-promise-all
|
|
jobP.catch(err => this.errorHandler(err));
|
|
} else
|
|
break;
|
|
}
|
|
if (nJobs > 0)
|
|
this.serverLog('debug', `${nJobs} jobs buffered`);
|
|
} catch (err) {
|
|
this.errorHandler(err);
|
|
}
|
|
}
|
|
|
|
this.polling = false;
|
|
}
|
|
async getJob() {
|
|
let jobId;
|
|
|
|
const conn = await this.pool.getConnection();
|
|
try {
|
|
await conn.beginTransaction();
|
|
try {
|
|
const [[printJob]] = await conn.query(selectQuery);
|
|
if (printJob) {
|
|
jobId = printJob.id;
|
|
await conn.query(printingQuery, [
|
|
'printing',
|
|
now(),
|
|
null,
|
|
null,
|
|
this.conf.serverId,
|
|
jobId
|
|
]);
|
|
this.jobLog(jobId, 'debug', 'get: printing');
|
|
await conn.commit();
|
|
} else
|
|
await conn.rollback();
|
|
} catch (err) {
|
|
try {
|
|
await conn.rollback();
|
|
} catch (e) {}
|
|
if (jobId)
|
|
this.jobLog(jobId, 'error', err.message);
|
|
throw err;
|
|
}
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
|
|
return jobId;
|
|
}
|
|
async printJob(jobId) {
|
|
const conf = this.conf;
|
|
let jobData;
|
|
const args = {};
|
|
let tmpFilePath;
|
|
let tmpFileCreated = false;
|
|
|
|
let conn;
|
|
try {
|
|
conn = await this.pool.getConnection();
|
|
|
|
// Job data
|
|
await conn.beginTransaction();
|
|
try {
|
|
const [[data]] = await conn.query(jobDataQuery, jobId);
|
|
jobData = data;
|
|
|
|
const [res] = await conn.query(jobArgsQuery, jobId);
|
|
for (const row of res)
|
|
args[row.name] = row.value;
|
|
} finally {
|
|
await conn.rollback();
|
|
}
|
|
|
|
// Path params
|
|
const usedParams = new Set();
|
|
const methodPath = jobData.method.replace(/{\w+}/g, function(match) {
|
|
const key = match.substr(1, match.length - 2);
|
|
const value = args[key];
|
|
usedParams.add(key);
|
|
return value !== undefined ? value : match;
|
|
});
|
|
|
|
// URL params
|
|
const params = {userFk: jobData.userFk};
|
|
for (const key in args) {
|
|
if (!usedParams.has(key))
|
|
params[key] = args[key];
|
|
}
|
|
const urlParams = new URLSearchParams(params);
|
|
|
|
const url = `${methodPath}?${urlParams.toString()}`;
|
|
this.jobLog(jobId, 'debug', `api: ${url}`);
|
|
|
|
// Request
|
|
let pdfData;
|
|
for (let attempts = 0; !pdfData && attempts < this.conf.retryAttempts; attempts++) {
|
|
try {
|
|
const res = await this.api({
|
|
method: 'get',
|
|
url,
|
|
responseType: 'arraybuffer',
|
|
headers: {'Accept': 'application/pdf'}
|
|
});
|
|
pdfData = res.data;
|
|
}
|
|
catch (err) {
|
|
if (err.name === 'AxiosError' && attempts < this.conf.retryAttempts - 1) {
|
|
const res = err.response;
|
|
switch(res.status) {
|
|
case 401: // Unauthorized
|
|
await this.getToken();
|
|
break;
|
|
case 502 || 504: // Bad Gateway & Gateway Timeout
|
|
await new Promise(
|
|
resolve => setTimeout(resolve, this.conf.retryTimeout));
|
|
break;
|
|
default:
|
|
try {
|
|
const resMessage = JSON.parse(res.data).error.message;
|
|
const resErr = new Error(`${err.message}: ${resMessage}`);
|
|
resErr.stack = err.stack;
|
|
throw resErr;
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
}
|
|
} else
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Save PDF to disk
|
|
const printer = jobData.printer;
|
|
const tmpPath = conf.tmpDir;
|
|
if (!fs.existsSync(tmpPath))
|
|
fs.mkdirSync(tmpPath)
|
|
tmpFilePath = path.join(tmpPath, `job-${jobId}.pdf`);
|
|
await fs.writeFile(tmpFilePath, pdfData, 'binary');
|
|
tmpFileCreated = true;
|
|
|
|
// Print PDF
|
|
const printCommand = `lp -d "${printer}" "${tmpFilePath}"`;
|
|
this.jobLog(jobId, 'debug', `print: ${printCommand}`);
|
|
try {
|
|
if (!conf.dryPrint) await pExec(printCommand);
|
|
} catch(err) {
|
|
throw new Error(`Print error: ${err.message}`);
|
|
}
|
|
|
|
await conn.query(printedQuery, ['printed', now(), jobId]);
|
|
this.jobLog(jobId, 'log', `${jobData.report}: '${printCommand}': GET ${url}`);
|
|
} catch (err) {
|
|
try {
|
|
await conn.query(errorQuery, ['error', now(), err.message, jobId]);
|
|
} catch (e) {
|
|
this.jobLog(jobId, 'error', e.message);
|
|
}
|
|
|
|
this.jobLog(jobId, 'error', err.message);
|
|
const jobErr = new Error(`(${jobId}) ${err.message}`);
|
|
jobErr.stack = err.stack;
|
|
throw jobErr;
|
|
} finally {
|
|
if (conn) conn.release();
|
|
|
|
if (!conf.keepFile) {
|
|
try {
|
|
const shouldDelete = tmpFileCreated
|
|
|| (tmpFilePath && await fs.pathExists(tmpFilePath));
|
|
if (shouldDelete) await fs.unlink(tmpFilePath);
|
|
} catch (err) {
|
|
this.jobLog(jobId, 'error', err.message);
|
|
}
|
|
}
|
|
|
|
const index = this.jobs.indexOf(jobId);
|
|
if (index !== -1) this.jobs.splice(index, 1);
|
|
setTimeout(() => this.getJobs());
|
|
}
|
|
}
|
|
jobLog(jobId, realm, message) {
|
|
this.log(`Job[${colors.yellow(jobId)}]`, realm, message);
|
|
}
|
|
}
|
|
|
|
module.exports = PrintServer;
|
|
|
|
function pExec(command) {
|
|
return new Promise(function(resolve, reject) {
|
|
exec(command, function(err, stdout, stderr) {
|
|
if (err) return reject(err);
|
|
resolve({stdout, stderr})
|
|
});
|
|
});
|
|
}
|
|
|
|
function now() {
|
|
return new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
} |