diff --git a/README.md b/README.md index 6305ed4..2e860a1 100644 --- a/README.md +++ b/README.md @@ -4,29 +4,36 @@ HTTP to IPP proxy, a.k.a. HSWAW Rube Goldberg printing microservice. ## Usage -TODO: This API is not stable, and is subject to change. Parameters will be added to specify printer kind, and some job attributes (like copy count, orientation, size...) - -``` -POST /api/1/print - Print a document - - body: PDF - - response: 200 OK if sent successfully (does not check if print *actually* succeeded, just that adding it to the print queue was successful), 400 or 500 in case of errors - -Debug routes: - -GET /api/1/printer/attributes - Prints IPP attributes -GET /api/1/printer/jobs - Lists IPP jobs -GET /api/1/health - Just says 200 OK -``` +`GET /` to see available API routes ## Quick start +Configuration available: + ``` -PRINTSERVANT_PORT=3199 PRINTSERVANT_IPP_PRINTER_URL="ipp://printmaster.local:631/printers/DYMO_LabelWriter450" node index.js +PRINTSERVANT_PORT - Port (default: 3199) +PRINTSERVANT_CONFIG - JSON config, like so: + { + printers: [ + { + name: 'DYMO_LabelWriter450', + aliases: ['dymo', 'label'], + ipp_url: 'ipp://$SECRET@printmaster.waw.hackerspace.pl/printers/DYMO_LabelWriter450', + }, + ... + ] + } +PRINTSERVANT_SECRET - Optional, used to substitute $SECRET in printer configs' ipp_urls +``` + + +``` +PRINTSERVANT_CONFIG='{"printers":[{"name":"DYMO_LabelWriter450","aliases":["dymo"],"ipp_url":"ipp://printmaster.local:631/printers/DYMO_LabelWriter450"}]}' node index.js ``` or: ``` docker build -t printservant . -docker run --env PRINTSERVANT_IPP_PRINTER_URL="ipp://printmaster.local:631/printers/DYMO_LabelWriter450" -it -p 3199:3199 printservant +docker run --env PRINTSERVANT_CONFIG='... put config here ...' -it -p 3199:3199 printservant ``` diff --git a/index.js b/index.js index cb45149..10a3637 100644 --- a/index.js +++ b/index.js @@ -1,35 +1,90 @@ +require('console-stamp')(console) + const ipp = require('ipp') const express = require('express') const bodyParser = require('body-parser') -// TODO: -// - multiple printers -// - take printer name, other params by query string -const PORT = Number(process.env.PRINTSERVANT_PORT) || 3199 -const PRINTER_URL = process.env.PRINTSERVANT_IPP_PRINTER_URL +// --- Config --- -if (!(PORT && PRINTER_URL)) { - console.error("PRINTSERVANT_PORT and PRINTSERVANT_IPP_PRINTER_URL environment variables must be set") - process.exit(1) +const PORT = Number(process.env.PRINTSERVANT_PORT) || 3199 +const CONFIG = JSON.parse(process.env.PRINTSERVANT_CONFIG || 'null') +const SECRET = process.env.PRINTSERVANT_SECRET || '' + +if (!(PORT && CONFIG)) { + throw new Error("PRINTSERVANT_PORT and PRINTSERVANT_CONFIG environment variables must be set") } +const printers = [] + +for (const printerConfig of CONFIG.printers) { + const { name, aliases = [], ipp_url } = printerConfig + + if (!(name && ipp_url)) { + throw new Error("Printer config must have a name and url") + } + + if (ipp_url.includes('$SECRET') && !SECRET) { + throw new Error(`Printer '${name}' config has a secret in the url but no PRINTSERVANT_CONFIG was provided`) + } + + const url = ipp_url.replace('$SECRET', SECRET) + const ippPrinter = ipp.Printer(url) + + printers.push({ name, aliases, ippPrinter }) +} + +const printerMap = new Map() +printers.forEach(printer => { + printerMap.set(printer.name.toLowerCase(), printer) + printer.aliases.forEach(alias => { + printerMap.set(alias.toLowerCase(), printer) + }) +}) + +function getPrinter(name) { + return printerMap.get(name.toLowerCase()) +} + +// --- Debug API --- + const app = express() -const printer = ipp.Printer(PRINTER_URL) app.get('/', (req, res) => { - res.send('Hello World! I am printservant.') + res.send(`
Hello world! API routes available:
+    - GET /
+    - GET /health
+    - GET /printers
+      - Lists all printers (names and aliases)
+    - GET /printers/:name/attributes
+      - Prints IPP attributes
+    - GET /printers/:name/jobs
+      - Prints IPP jobs
+    - POST /print?printer=:name (body: application/pdf)
+      - Prints a PDF
+      - :name - printer name or alias (case insensitive)
+      - Response: 200 OK if sent to printer successfully (not necessarily printed), 4xx/5xx otherwise
+  
`) }) -app.get('/api/1/health', (req, res) => { - res.send("I'm cool, I think. I'm not sure. Thanks for asking though.") +app.get('/health', (req, res) => { + res.send(`Printservant is healthy (probably)`) }) -function executeSimpleCommand(command, res) { - printer.execute(command, null, function(error, result){ - if (error) { - console.log(error); - res.status(500) - } else if (result.statusCode !== 'successful-ok') { +app.get('/printers', (req, res) => { + res.send(printers.map(({ name, aliases }) => ({ name, aliases }))) +}) + +function executeSimpleCommand(printerName, command, res) { + const printer = getPrinter(printerName) + if (!printer) { + console.log(`Printer '${printerName}' not found`) + res.status(404).send(`Printer '${printerName}' not found`) + return + } + + printer.ippPrinter.execute(command, null, function (error, result) { + if (error || result.statusCode !== 'successful-ok') { + console.error(error || result); res.status(500) } @@ -37,26 +92,54 @@ function executeSimpleCommand(command, res) { }); } -app.get('/api/1/printer/attributes', (req, res) => { - console.log('Getting printer attributes') - executeSimpleCommand("Get-Printer-Attributes", res) +app.get('/printers/:name/attributes', (req, res) => { + const { name } = req.params + console.log(`Getting attributes for printer: '${name}'`) + executeSimpleCommand(name, "Get-Printer-Attributes", res) }) -app.get('/api/1/printer/jobs', (req, res) => { - console.log('Getting printer jobs') - executeSimpleCommand("Get-Jobs", res) +app.get('/printers/:name/jobs', (req, res) => { + const { name } = req.params + console.log(`Getting jobs for printer: '${name}'`) + executeSimpleCommand(name, "Get-Jobs", res) }) -app.post('/api/1/print', bodyParser.raw({ type: '*/*' }), (req, res) => { +// --- Print API --- + +app.post('/print', bodyParser.raw({ type: '*/*' }), (req, res) => { const { body, query } = req - console.log("Received print job: ", { body, query }) - if (!body || !body.length) { - res.status(400).send("No data received, please send a PDF") + // validate query params + const { printer: printerName, ...otherParams } = query + if (!printerName) { + res.status(400).send("No printer specified, pass ?printer=:name in the query params") + return + } else if (Object.keys(otherParams).length) { + res.status(400).send("Unknown query params, only ?printer=:name is supported") return } + const printer = getPrinter(printerName) + if (!printer) { + console.log(`Printer '${name}' not found`) + res.status(404).send(`Printer '${name}' not found`) + return + } + + // validate body + if (!body || !body.length) { + res.status(400).send("No data received, please send a PDF") + return + } else if (body.subarray(0, 4).toString() !== "%PDF") { + res.status(415).send("Data does not look like a PDF, please send a PDF") + return + } else if (body.length > 10_000_000) { + res.status(413).send("Data too large (limit: 10MB), please send a smaller PDF") + return + } + + // send print job const msg = { "operation-attributes-tag": { "document-format": "application/pdf", @@ -69,7 +152,7 @@ app.post('/api/1/print', bodyParser.raw({ type: '*/*' }), (req, res) => { }; console.log("Sending print job: ", msg) - printer.execute("Print-Job", msg, function(error, result){ + printer.ippPrinter.execute("Print-Job", msg, function(error, result){ console.log({ result, error }); if (error) { @@ -82,6 +165,8 @@ app.post('/api/1/print', bodyParser.raw({ type: '*/*' }), (req, res) => { }); }) +// --- Let's goooooooo --- + app.listen(PORT, () => { console.log(`printservant listening on port ${PORT}`) }) diff --git a/package-lock.json b/package-lock.json index 700f3af..9adc7ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,11 @@ "": { "name": "printservant", "version": "0.0.1", + "hasInstallScript": true, "license": "MIT", "dependencies": { "body-parser": "^1.20.2", + "console-stamp": "^3.1.2", "express": "^4.18.2", "ipp": "^2.0.1", "patch-package": "^8.0.0" @@ -178,6 +180,18 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/console-stamp": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/console-stamp/-/console-stamp-3.1.2.tgz", + "integrity": "sha512-ab66x3NxOTxPuq71dI6gXEiw2X6ql4Le5gZz0bm7FW3FSCB00eztra/oQUuCoCGlsyKOxtULnHwphzMrRtzMBg==", + "dependencies": { + "chalk": "^4.1.2", + "dateformat": "^4.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -223,6 +237,14 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/package.json b/package.json index e4de331..aaf9035 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "body-parser": "^1.20.2", + "console-stamp": "^3.1.2", "express": "^4.18.2", "ipp": "^2.0.1", "patch-package": "^8.0.0"