/** * printDispatch — the ONE place a PDF is handed to a printer. * * printFile(event, { queue, options, filePath, source }) → PrintResult * printFiles(event, [...]) → PrintResult[] (shared batchId) * stagePrintFile(pathName, fileName, buffer) → absolute path under the spool dir * * Routing is decided PER PRINTER (never per org/tenant): the queue name is looked up in the * instance-local binding table (printSpoolDb.findBindingForQueue, keyed by CUST_Printer.CupsName). * no binding → driver 'local' : `lp -d …` on this host, exactly what the routes did * before (our own prod keeps running unchanged); * binding → driver 'agent' : the job is spooled for the tenant's LogShip Print Agent * (server/api/print-agent/*), which runs the identical lp * command inside its LAN. Response status 'queued' + jobId. * * Security: the former routes interpolated request values into a shell string. Here the * queue must match SAFE_QUEUE, the option string is tokenised and whitelisted, and the local * driver uses execFile (no shell at all). * * The result is never thrown: `ok:false` + `error` so every caller keeps its own response shape. */ import { execFile } from 'node:child_process' import { existsSync, mkdirSync } from 'node:fs' import { writeFile, stat } from 'node:fs/promises' import path from 'node:path' import getTokenHelper from './getTokenHelper' import { enqueueJobs, findBindingForQueue, hasAnyBindings, isAgentOnline, newBatchId, type NewPrintJob } from './printSpoolDb' export interface PrintJobSpec { /** CUPS queue name; '' = the host's CUPS default printer (local driver only) */ queue: string /** CUPS option string, e.g. '-o fit-to-page -o media=A4' */ options?: string /** absolute path of the staged PDF */ filePath: string /** route name for the job log, e.g. 'print/labelprint' */ source: string batchId?: string | null } export interface PrintResult { ok: boolean status: 'printed' | 'queued' | 'failed' driver: 'local' | 'agent' queue: string options: string stdout: string stderr: string error?: string /** CUPS "request id is -" (local driver) */ requestId?: string jobId?: number batchId?: string | null agentId?: number agentName?: string agentOnline?: boolean } export const SAFE_QUEUE = /^[A-Za-z0-9_.-]{1,64}$/ const SAFE_OPTION_VALUE = /^[A-Za-z0-9=_.,:+\-]{1,80}$/ const SAFE_FLAG = /^-(o|n|t|H|P|q)$/ const DEFAULT_STORAGE = '/root/storage' /** Spool/staging root: runtimeConfig.printStorageDir (PRINT_STORAGE_DIR / NUXT_PRINT_STORAGE_DIR). */ export const printStorageDir = (): string => { try { const cfg: any = useRuntimeConfig() const dir = String(cfg?.printStorageDir || '').trim() if (dir) return dir } catch { /* outside a Nitro context */ } return process.env.PRINT_STORAGE_DIR || DEFAULT_STORAGE } export const sanitizePathName = (p: any, fallback = 'print') => String(p ?? '').replace(/\\/g, '/').replace(/\.\.+/g, '_').replace(/[^A-Za-z0-9/_.-]/g, '_').replace(/^\/+/, '').replace(/\/+$/, '') || fallback export const sanitizeFileName = (f: any, fallback = 'document.pdf') => String(f ?? '').replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '') || fallback /** Writes the PDF under the storage root and returns its absolute path. */ export const stagePrintFile = async (pathName: any, fileName: any, content: Buffer): Promise => { const dir = path.join(printStorageDir(), sanitizePathName(pathName)) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) const full = path.join(dir, sanitizeFileName(fileName)) await writeFile(full, content) return full } /** * Tokenises a CUPS option string and rejects anything that is not `-o key[=value]`, * `-n `, `-t ` etc. Throws on an unsafe token. */ export const parsePrintOptions = (options: any): string[] => { const tokens = String(options ?? '').trim().split(/\s+/).filter(Boolean) const out: string[] = [] for (let i = 0; i < tokens.length; i++) { const t = tokens[i] as string if (SAFE_FLAG.test(t)) { const v = tokens[i + 1] as string | undefined if (!v || !SAFE_OPTION_VALUE.test(v)) throw new Error(`invalid print option value after ${t}`) if (t === '-n' && !/^\d{1,3}$/.test(v)) throw new Error('invalid copy count') out.push(t, v) i++ continue } // "-ofit-to-page" style (flag glued to its value) if (/^-o[A-Za-z]/.test(t) && SAFE_OPTION_VALUE.test(t.slice(2))) { out.push('-o', t.slice(2)); continue } throw new Error(`invalid print option token: ${t}`) } return out } const sessionIds = async (event: any): Promise<{ userId: number | null; roleId: number | null; orgId: number }> => { const orgId = Number(getCookie(event, 'logship_organization_id') || 0) || 0 let userId: number | null = Number(getCookie(event, 'logship_user_id') || 0) || null let roleId: number | null = Number(getCookie(event, 'logship_role_id') || 0) || null if (!userId || !roleId) { try { const token = await getTokenHelper(event) const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64').toString()) userId = userId || Number(payload?.AD_User_ID) || null roleId = roleId || Number(payload?.AD_Role_ID) || null } catch { /* anonymous (service) caller */ } } return { userId, roleId, orgId } } const runLp = (queue: string, optionTokens: string[], filePath: string): Promise<{ stdout: string; stderr: string }> => new Promise((resolve, reject) => { const args = [...(queue ? ['-d', queue] : []), ...optionTokens, filePath] execFile('lp', args, { timeout: 60_000, maxBuffer: 1024 * 1024, encoding: 'utf8' }, (err: any, stdout: string, stderr: string) => { if (err) { err.stdout = String(stdout || '') err.stderr = String(stderr || '') return reject(err) } resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') }) }) }) const parseRequestId = (stdout: string) => /request id is (\S+)/i.exec(stdout || '')?.[1] const failed = (spec: PrintJobSpec, driver: 'local' | 'agent', error: string, extra: Partial<PrintResult> = {}): PrintResult => ({ ok: false, status: 'failed', driver, queue: spec.queue, options: String(spec.options ?? '').trim(), stdout: '', stderr: '', error, ...extra }) /** * Prints several files as one batch. Local-driver items run sequentially (CUPS keeps the * order), agent-driver items are inserted in ONE transaction with a shared batchId. */ export const printFiles = async (event: any, specs: PrintJobSpec[]): Promise<PrintResult[]> => { const results: PrintResult[] = new Array(specs.length) const batchId = specs.length > 1 ? newBatchId() : null const agentItems: { idx: number; job: NewPrintJob; spec: PrintJobSpec; agent: any }[] = [] const anyBindings = hasAnyBindings() const ids = anyBindings ? await sessionIds(event) : { userId: null, roleId: null, orgId: 0 } for (let idx = 0; idx < specs.length; idx++) { const spec = specs[idx] as PrintJobSpec const queue = String(spec.queue ?? '').trim() if (queue && !SAFE_QUEUE.test(queue)) { results[idx] = failed(spec, 'local', `invalid printer queue name: ${queue}`); continue } let optionTokens: string[] try { optionTokens = parsePrintOptions(spec.options) } catch (e: any) { results[idx] = failed(spec, 'local', e?.message || 'invalid print options'); continue } if (!spec.filePath || !existsSync(spec.filePath)) { results[idx] = failed(spec, 'local', 'print file missing: ' + spec.filePath); continue } const hit = (anyBindings && queue) ? findBindingForQueue(queue, ids.orgId) : null if (hit) { let size = 0 try { size = (await stat(spec.filePath)).size } catch { /* keep 0 */ } agentItems.push({ idx, spec, agent: hit.agent, job: { agentId: hit.agent.id, queue, options: optionTokens.join(' '), filePath: spec.filePath, fileName: path.basename(spec.filePath), fileSize: size, source: spec.source || 'print', batchId: spec.batchId ?? batchId, createdByUser: ids.userId, createdByRole: ids.roleId } }) continue } // local driver — today's behaviour try { const { stdout, stderr } = await runLp(queue, optionTokens, spec.filePath) results[idx] = { ok: true, status: 'printed', driver: 'local', queue, options: optionTokens.join(' '), stdout, stderr, requestId: parseRequestId(stdout), batchId: spec.batchId ?? batchId } } catch (err: any) { results[idx] = failed(spec, 'local', err?.stderr || err?.message || 'lp failed', { stdout: err?.stdout || '', stderr: err?.stderr || '' }) } } if (agentItems.length) { try { const jobIds = enqueueJobs(agentItems.map(a => a.job)) agentItems.forEach((a, i) => { const jobId = jobIds[i] as number results[a.idx] = { ok: true, status: 'queued', driver: 'agent', queue: a.job.queue, options: a.job.options, stdout: `queued as print job ${jobId} for agent ${a.agent.name}`, stderr: '', jobId, batchId: a.job.batchId ?? null, agentId: a.agent.id, agentName: a.agent.name, agentOnline: isAgentOnline(a.agent) } }) console.log(`[printDispatch] queued ${jobIds.length} job(s) → agent(s) ${[...new Set(agentItems.map(a => a.agent.name))].join(', ')}${batchId ? ' batch ' + batchId : ''}`) } catch (err: any) { agentItems.forEach(a => { results[a.idx] = failed(a.spec, 'agent', 'spool insert failed: ' + (err?.message || err)) }) } } return results } export const printFile = async (event: any, spec: PrintJobSpec): Promise<PrintResult> => (await printFiles(event, [spec]))[0] /** * Merges a PrintResult into the legacy response object of the old routes * (`message: 'success'|'failed'`, stdout, stderr) and adds the new fields. */ export const applyPrintResult = (data: any, r: PrintResult) => { data.message = r.ok ? 'success' : 'failed' data.stdout = r.stdout data.stderr = r.stderr data.printDriver = r.driver data.printStatus = r.status data.printer = data.printer ?? r.queue if (r.options) data.printOptions = r.options if (r.jobId) data.jobId = r.jobId if (r.batchId) data.batchId = r.batchId if (r.agentName) data.agentName = r.agentName if (r.driver === 'agent') data.agentOnline = r.agentOnline if (r.error) data.error = r.error return data }