Public: anyone can read this; the crew can change it.
An app prints PWG raster through the print route, because the owner's printer… #11954
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11953-an-app-prints-pwg-raster-through-the-pri → main 3 commits fork
7 files
changed +364
−17
docs/features/devices.md +1 −1
| @@ -12,7 +12,7 @@ | ||
| 12 | 12 | ## Printing from an app |
| 13 | 13 | |
| 14 | 14 | - On a device's row ("Allowed apps") the owner grants it to an app by slug, and revokes it again (`device.granted` / `device.revoked` events). No grant, no print. In the terminal's Devices screen, `a` allows the device under the cursor to an app (type its slug) and `r` revokes one; the same actions and events as the page. |
| 15 | - An app prints with `POST $FORGE_API_URL/devices/<id>/print`, `Authorization: Bearer $FORGE_TOKEN`: the app process's own token, body the PDF (`content-type: application/pdf`). The forge knows the app from `app_token`, which only the runtime writes when it mints that token; a person's token named like an app is not one. The reply is `{"job":{"id","state","reasons"}}` from the printer's IPP Print-Job answer. | |
| 15 | - An app prints with `POST $FORGE_API_URL/devices/<id>/print`, `Authorization: Bearer $FORGE_TOKEN`: the app process's own token, body the PDF (`content-type: application/pdf`). The forge knows the app from `app_token`, which only the runtime writes when it mints that token; a person's token named like an app is not one. A printer that takes only PWG raster is printed to with `content-type: image/pwg-raster` (up to 80 MB, 20 pages; sent as document-format `image/pwg-raster`). Before anything leaves for the LAN the forge checks the `RaS2` sync word, walks every page header (1796 bytes) and its PackBits lines, and takes the page count from them; a truncated, mis-synced, odd-format or 21-page file is 422. Then it asks the printer (Get-Printer-Attributes) for `pwg-raster-document-resolution-supported` and `-type-supported` and refuses a page those lists rule out with 422 (`raster_resolution_refused`, `raster_type_refused`); a printer that lists nothing is not second-guessed. Width and height are not checked against the printer (IPP has no attribute for them). The body is buffered, as the PDF is. The reply is `{"job":{"id","state","reasons"}}` from the printer's IPP Print-Job answer. | |
| 16 | 16 | - Checked before anything leaves for the LAN: at most 10 MB and 20 pages, `%PDF-` header and `%%EOF` trailer, otherwise 422. Pages are counted from the plain page tree; a PDF whose tree is in a compressed object stream answers 422 `pages_unknown`. Not granted, or not a declared device: 403. Printer silent or refusing: 502. |
| 17 | 17 | - The route takes a device number only; the job goes to the declared address, so an app reaches no other LAN host through it. Its cell's own egress is unchanged (`src/apps/egress.ts` still opens only granted `host:port`s). |
| 18 | 18 | - Each print appends `device.printed` (device, app, pages). |
src/contract/openapi/routes/postApiV1DevicesIdPrint.ts +6 −1
| @@ -3,7 +3,7 @@ import type { Route } from "../shared.ts"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "post", |
| 5 | 5 | path: "/api/v1/devices/{id}/print", |
| 6 | summary: "Print the PDF in the request body on a device the app was granted (the app's own token); answers 200 with the job", | |
| 6 | summary: "Print the PDF (application/pdf) or PWG raster (image/pwg-raster, up to 80 MB, 20 pages) in the request body on a device the app was granted (the app's own token); answers 200 with the job", | |
| 7 | 7 | auth: true, |
| 8 | 8 | role: "member", |
| 9 | 9 | refusals: { |
| @@ -13,6 +13,11 @@ export const route: Route = { | ||
| 13 | 13 | not_pdf: "422: the body is not a PDF", |
| 14 | 14 | too_many_pages: "422: the PDF has more pages than the grant allows", |
| 15 | 15 | pages_unknown: "422: the page count could not be read", |
| 16 | not_raster: "422: the body does not begin with the PWG raster sync word RaS2", | |
| 17 | raster_truncated: "422: a PWG raster page header or its lines are cut short", | |
| 18 | raster_unsupported: "422: a PWG raster page has a format the forge cannot read", | |
| 19 | raster_resolution_refused: "422: the printer's pwg-raster-document-resolution-supported does not list the page's resolution", | |
| 20 | raster_type_refused: "422: the printer's pwg-raster-document-type-supported does not list the page's type", | |
| 16 | 21 | printer_unreachable: "502: the printer did not answer", |
| 17 | 22 | printer_refused: "502: the printer refused the job", |
| 18 | 23 | unauthorized: "no token", |
src/devices/ipp.ts +38 −5
| @@ -32,6 +32,8 @@ const WANTED = [ | ||
| 32 | 32 | "marker-colors", |
| 33 | 33 | ]; |
| 34 | 34 | |
| 35 | const RASTER_WANTED = ["pwg-raster-document-resolution-supported", "pwg-raster-document-type-supported"]; | |
| 36 | ||
| 35 | 37 | const RESPONSE_MAX = 256 * 1024; |
| 36 | 38 | |
| 37 | 39 | export type IppValue = string | number | boolean | Uint8Array; |
| @@ -46,7 +48,7 @@ function attr(tag: number, name: string, value: string): number[] { | ||
| 46 | 48 | } |
| 47 | 49 | |
| 48 | 50 | /** The request body for Get-Printer-Attributes on `printerUri`. */ |
| 49 | export function encodeGetPrinterAttributes(printerUri: string, requestId = 1): Uint8Array { | |
| 51 | export function encodeGetPrinterAttributes(printerUri: string, requestId = 1, wanted: readonly string[] = WANTED): Uint8Array { | |
| 50 | 52 | const out: number[] = [ |
| 51 | 53 | 1, 1, // version 1.1 |
| 52 | 54 | OP_GET_PRINTER_ATTRIBUTES >> 8, OP_GET_PRINTER_ATTRIBUTES & 255, |
| @@ -56,7 +58,7 @@ export function encodeGetPrinterAttributes(printerUri: string, requestId = 1): U | ||
| 56 | 58 | ...attr(TAG.language, "attributes-natural-language", "en"), |
| 57 | 59 | ...attr(TAG.uri, "printer-uri", printerUri), |
| 58 | 60 | ]; |
| 59 | WANTED.forEach((k, i) => out.push(...attr(TAG.keyword, i === 0 ? "requested-attributes" : "", k))); | |
| 61 | wanted.forEach((k, i) => out.push(...attr(TAG.keyword, i === 0 ? "requested-attributes" : "", k))); | |
| 60 | 62 | out.push(TAG.end); |
| 61 | 63 | return Uint8Array.from(out); |
| 62 | 64 | } |
| @@ -166,6 +168,32 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe | ||
| 166 | 168 | return { ok: true, report: reportOf(answer.response.attributes) }; |
| 167 | 169 | } |
| 168 | 170 | |
| 171 | export interface RasterSupport { | |
| 172 | /** [x, y] dpi pairs the printer lists, or undefined when it lists none. */ | |
| 173 | readonly resolutions: [number, number][] | undefined; | |
| 174 | readonly types: string[] | undefined; | |
| 175 | } | |
| 176 | ||
| 177 | /** | |
| 178 | * What the printer says about PWG raster: `pwg-raster-document-resolution-supported` | |
| 179 | * and `-type-supported`. | |
| 180 | */ | |
| 181 | export async function probeRasterSupport(url: string, timeoutMs = 2500): Promise<{ ok: true; support: RasterSupport } | { ok: false; error: "unreachable" | "refused" | "bad_answer" }> { | |
| 182 | const answer = await exchange(url, encodeGetPrinterAttributes(url, 1, RASTER_WANTED), timeoutMs); | |
| 183 | if (!answer.ok) return answer; | |
| 184 | const a = answer.response.attributes; | |
| 185 | const resolutions: [number, number][] = []; | |
| 186 | for (const v of a.get("pwg-raster-document-resolution-supported") ?? []) { | |
| 187 | // resolution value: x (int32), y (int32), units (1 byte; 3 = dots per inch) | |
| 188 | if (v instanceof Uint8Array && v.length === 9 && v[8] === 3) { | |
| 189 | const d = new DataView(v.buffer, v.byteOffset, 9); | |
| 190 | resolutions.push([d.getInt32(0), d.getInt32(4)]); | |
| 191 | } | |
| 192 | } | |
| 193 | const types = str(a, "pwg-raster-document-type-supported"); | |
| 194 | return { ok: true, support: { resolutions: resolutions.length > 0 ? resolutions : undefined, types: types.length > 0 ? types : undefined } }; | |
| 195 | } | |
| 196 | ||
| 169 | 197 | type Exchange = |
| 170 | 198 | | { readonly ok: true; readonly response: IppResponse } |
| 171 | 199 | | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" }; |
| @@ -200,7 +228,7 @@ async function exchange(url: string, body: Uint8Array, timeoutMs: number): Promi | ||
| 200 | 228 | } |
| 201 | 229 | |
| 202 | 230 | /** The request body for Print-Job: the document follows the attributes. */ |
| 203 | export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1): Uint8Array { | |
| 231 | export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1, format = "application/pdf"): Uint8Array { | |
| 204 | 232 | const head: number[] = [ |
| 205 | 233 | 1, 1, |
| 206 | 234 | OP_PRINT_JOB >> 8, OP_PRINT_JOB & 255, |
| @@ -211,7 +239,7 @@ export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: str | ||
| 211 | 239 | ...attr(TAG.uri, "printer-uri", printerUri), |
| 212 | 240 | ...attr(TAG.nameWithoutLanguage, "requesting-user-name", "forge"), |
| 213 | 241 | ...attr(TAG.nameWithoutLanguage, "job-name", jobName), |
| 214 | ...attr(TAG.mimeType, "document-format", "application/pdf"), | |
| 242 | ...attr(TAG.mimeType, "document-format", format), | |
| 215 | 243 | TAG.end, |
| 216 | 244 | ]; |
| 217 | 245 | const out = new Uint8Array(head.length + pdf.length); |
| @@ -243,7 +271,12 @@ export type PrintResult = | ||
| 243 | 271 | * takes a long document needs longer than a probe does. |
| 244 | 272 | */ |
| 245 | 273 | export async function printPdf(url: string, pdf: Uint8Array, jobName: string, timeoutMs = 30_000): Promise<PrintResult> { |
| 246 | const answer = await exchange(url, encodePrintJob(url, pdf, jobName), timeoutMs); | |
| 274 | return printDocument(url, pdf, jobName, "application/pdf", timeoutMs); | |
| 275 | } | |
| 276 | ||
| 277 | /** The same Print-Job with another document-format (PWG raster is `image/pwg-raster`). */ | |
| 278 | export async function printDocument(url: string, doc: Uint8Array, jobName: string, format: string, timeoutMs = 30_000): Promise<PrintResult> { | |
| 279 | const answer = await exchange(url, encodePrintJob(url, doc, jobName, 1, format), timeoutMs); | |
| 247 | 280 | if (!answer.ok) return answer; |
| 248 | 281 | const a = answer.response.attributes; |
| 249 | 282 | const id = a.get("job-id")?.[0]; |
src/devices/print.ts +37 −7
| @@ -11,7 +11,8 @@ import { getDevice, type Device } from "../core/devices.ts"; | ||
| 11 | 11 | import { dailyCap, logJob, printedToday, type Outcome } from "../core/print_log.ts"; |
| 12 | 12 | import { RateLimiter } from "../core/ratelimit.ts"; |
| 13 | 13 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 14 | import { printPdf, type PrintJob } from "./ipp.ts"; | |
| 14 | import { printDocument, printPdf, probeRasterSupport, type PrintJob } from "./ipp.ts"; | |
| 15 | import { PWG_MAX_BYTES, readPwg, unsupportedBy, type PwgError } from "./pwg.ts"; | |
| 15 | 16 | |
| 16 | 17 | export const PDF_MAX_BYTES = 10 * 1024 * 1024; |
| 17 | 18 | export const PDF_MAX_PAGES = 20; |
| @@ -20,16 +21,21 @@ export const PDF_MAX_PAGES = 20; | ||
| 20 | 21 | export const PRINTS_PER_MINUTE = 10; |
| 21 | 22 | export const PRINTS_AT_ONCE = 2; |
| 22 | 23 | |
| 23 | export type PrintError = "forbidden" | "rate_limited" | "daily_cap" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused"; | |
| 24 | export type PrintError = "forbidden" | "rate_limited" | "daily_cap" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused" | PwgError | "raster_resolution_refused" | "raster_type_refused"; | |
| 24 | 25 | |
| 25 | 26 | export const PRINT_WORDS: Record<PrintError, string> = { |
| 26 | 27 | forbidden: "This app is not granted that printer.", |
| 27 | 28 | rate_limited: "This app is printing too fast; wait a little.", |
| 28 | 29 | daily_cap: "This app has used its prints for today on this printer; it can print again tomorrow.", |
| 29 | 30 | not_pdf: "The body must be a PDF (application/pdf).", |
| 30 | too_large: "The PDF is over 10 MB.", | |
| 31 | too_many_pages: "The PDF is over 20 pages.", | |
| 31 | too_large: "The document is over its size limit (10 MB for a PDF, 80 MB for PWG raster).", | |
| 32 | too_many_pages: "The document is over 20 pages.", | |
| 32 | 33 | pages_unknown: "The page count could not be read; save the PDF without compressed object streams.", |
| 34 | not_raster: "The body must be PWG raster (image/pwg-raster): it does not begin with the RaS2 sync word.", | |
| 35 | raster_truncated: "The PWG raster is cut short: a page header or its lines end before they should.", | |
| 36 | raster_unsupported: "The PWG raster has a page this forge cannot read: it takes 8 or 16 bits of sgray, srgb or adobe-rgb, with consistent header sizes.", | |
| 37 | raster_resolution_refused: "The printer says it does not take PWG raster at this resolution.", | |
| 38 | raster_type_refused: "The printer says it does not take PWG raster in this colour space and depth.", | |
| 33 | 39 | printer_unreachable: "The printer did not answer.", |
| 34 | 40 | printer_refused: "The printer did not accept the job.", |
| 35 | 41 | }; |
| @@ -68,6 +74,13 @@ export function checkPdf(pdf: Uint8Array): Result<number, PrintError> { | ||
| 68 | 74 | return ok(pages); |
| 69 | 75 | } |
| 70 | 76 | |
| 77 | /** The body is PWG raster within the limits: its page count, or why not. */ | |
| 78 | export function checkRaster(raster: Uint8Array): Result<number, PrintError> { | |
| 79 | if (raster.length > PWG_MAX_BYTES) return err("too_large"); | |
| 80 | const pages = readPwg(raster); | |
| 81 | return pages.ok ? ok(pages.value.length) : pages; | |
| 82 | } | |
| 83 | ||
| 71 | 84 | const perMinute = new RateLimiter({ perMinute: PRINTS_PER_MINUTE, adminPerMinute: PRINTS_PER_MINUTE, enabled: true }); |
| 72 | 85 | const inFlight = new Map<string, number>(); |
| 73 | 86 | |
| @@ -114,16 +127,33 @@ export function permitPrint(app: App, secret: string, deviceId: number): Result< | ||
| 114 | 127 | } |
| 115 | 128 | |
| 116 | 129 | /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */ |
| 117 | export async function printFor(app: App, permit: Permit, pdf: Uint8Array): Promise<Result<PrintJob, PrintError>> { | |
| 130 | export async function printFor(app: App, permit: Permit, pdf: Uint8Array, kind: "pdf" | "raster" = "pdf"): Promise<Result<PrintJob, PrintError>> { | |
| 118 | 131 | try { |
| 119 | 132 | const { caller, device } = permit; |
| 120 | const checked = checkPdf(pdf); | |
| 133 | const checked = kind === "raster" ? checkRaster(pdf) : checkPdf(pdf); | |
| 121 | 134 | const log = (pages: number, outcome: Outcome): void => logJob(app.db, { deviceId: device.id, appId: caller.id, pages, outcome }); |
| 122 | 135 | if (!checked.ok) { |
| 123 | 136 | log(0, "refused_document"); |
| 124 | 137 | return checked; |
| 125 | 138 | } |
| 126 | const sent = await printPdf(device.url, pdf, `${caller.slug} (forge)`); | |
| 139 | if (kind === "raster") { | |
| 140 | // Only now does anything touch the LAN: ask what the printer takes of what the pages need. | |
| 141 | const asked = await probeRasterSupport(device.url); | |
| 142 | if (!asked.ok) { | |
| 143 | log(0, "failed"); | |
| 144 | return err(asked.error === "unreachable" ? "printer_unreachable" : "printer_refused"); | |
| 145 | } | |
| 146 | const pages = readPwg(pdf); | |
| 147 | for (const page of pages.ok ? pages.value : []) { | |
| 148 | const why = unsupportedBy(page, asked.support.resolutions, asked.support.types); | |
| 149 | if (why !== undefined) { | |
| 150 | log(0, "refused_document"); | |
| 151 | return err(why); | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | const jobName = `${caller.slug} (forge)`; | |
| 156 | const sent = kind === "raster" ? await printDocument(device.url, pdf, jobName, "image/pwg-raster") : await printPdf(device.url, pdf, jobName); | |
| 127 | 157 | log(checked.value, sent.ok ? "printed" : "failed"); |
| 128 | 158 | releasePermit(permit); |
| 129 | 159 | if (sent.ok) { |
src/devices/pwg.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * PWG raster (PWG 5102.4) read just far enough to vouch for it: the `RaS2` | |
| 3 | * sync word, then per page a 1796-byte header and PackBits-coded lines. Every | |
| 4 | * page is walked, because the only way to find the next header is to decode | |
| 5 | * this page's lines; nothing is sent to a printer that this walk refuses. | |
| 6 | * Plain bytes in, plain data out. | |
| 7 | */ | |
| 8 | ||
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | ||
| 11 | export const PWG_MAX_BYTES = 80 * 1024 * 1024; | |
| 12 | export const PWG_MAX_PAGES = 20; | |
| 13 | export const PWG_HEADER_BYTES = 1796; | |
| 14 | /** Sides and line length no real page reaches; keeps a forged header from making a long walk. */ | |
| 15 | const MAX_SIDE = 40_000; | |
| 16 | ||
| 17 | export interface PwgPage { | |
| 18 | readonly width: number; | |
| 19 | readonly height: number; | |
| 20 | /** Dots per inch, across and down. */ | |
| 21 | readonly dpi: readonly [number, number]; | |
| 22 | /** The IPP keyword for the pixel format, e.g. `sgray_8`. */ | |
| 23 | readonly type: string; | |
| 24 | } | |
| 25 | ||
| 26 | export type PwgError = "not_raster" | "raster_truncated" | "too_many_pages" | "raster_unsupported"; | |
| 27 | ||
| 28 | /** cupsColorSpace values PWG raster allows, with their channel counts and keyword stems. */ | |
| 29 | const SPACES: Record<number, { channels: number; stem: string }> = { | |
| 30 | 18: { channels: 1, stem: "sgray" }, | |
| 31 | 19: { channels: 3, stem: "srgb" }, | |
| 32 | 20: { channels: 3, stem: "adobe-rgb" }, | |
| 33 | }; | |
| 34 | ||
| 35 | /** The pages of a PWG raster document, or why it is refused. */ | |
| 36 | export function readPwg(bytes: Uint8Array): Result<PwgPage[], PwgError> { | |
| 37 | if (bytes.length < 4 || String.fromCharCode(bytes[0]!, bytes[1]!, bytes[2]!, bytes[3]!) !== "RaS2") return err("not_raster"); | |
| 38 | const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); | |
| 39 | const pages: PwgPage[] = []; | |
| 40 | let at = 4; | |
| 41 | while (at < bytes.length) { | |
| 42 | if (pages.length === PWG_MAX_PAGES) return err("too_many_pages"); | |
| 43 | if (at + PWG_HEADER_BYTES > bytes.length) return err("raster_truncated"); | |
| 44 | const h = at; | |
| 45 | const dpi: [number, number] = [view.getUint32(h + 276), view.getUint32(h + 280)]; | |
| 46 | const width = view.getUint32(h + 372); | |
| 47 | const height = view.getUint32(h + 376); | |
| 48 | const bitsPerColor = view.getUint32(h + 384); | |
| 49 | const bitsPerPixel = view.getUint32(h + 388); | |
| 50 | const bytesPerLine = view.getUint32(h + 392); | |
| 51 | const space = SPACES[view.getUint32(h + 400)]; | |
| 52 | if (!space || (bitsPerColor !== 8 && bitsPerColor !== 16) || bitsPerPixel !== bitsPerColor * space.channels) return err("raster_unsupported"); | |
| 53 | if (width < 1 || height < 1 || width > MAX_SIDE || height > MAX_SIDE || dpi[0] < 1 || dpi[1] < 1) return err("raster_unsupported"); | |
| 54 | if (bytesPerLine !== Math.ceil((width * bitsPerPixel) / 8)) return err("raster_unsupported"); | |
| 55 | at += PWG_HEADER_BYTES; | |
| 56 | const pixel = bitsPerPixel / 8; | |
| 57 | let rows = 0; | |
| 58 | while (rows < height) { | |
| 59 | if (at >= bytes.length) return err("raster_truncated"); | |
| 60 | const repeat = bytes[at++]! + 1; | |
| 61 | let line = 0; | |
| 62 | while (line < bytesPerLine) { | |
| 63 | if (at >= bytes.length) return err("raster_truncated"); | |
| 64 | const c = bytes[at++]!; | |
| 65 | if (c === 128) break; // the rest of the line is white | |
| 66 | const run = c < 128 ? c + 1 : 257 - c; | |
| 67 | line += run * pixel; | |
| 68 | at += c < 128 ? pixel : run * pixel; | |
| 69 | if (line > bytesPerLine) return err("raster_unsupported"); | |
| 70 | } | |
| 71 | if (at > bytes.length) return err("raster_truncated"); | |
| 72 | rows += repeat; | |
| 73 | if (rows > height) return err("raster_unsupported"); | |
| 74 | } | |
| 75 | pages.push({ width, height, dpi, type: `${space.stem}_${bitsPerColor}` }); | |
| 76 | } | |
| 77 | if (pages.length === 0) return err("raster_truncated"); | |
| 78 | return ok(pages); | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * Whether the printer's answers (`pwg-raster-document-resolution-supported`, | |
| 83 | * `pwg-raster-document-type-supported`) rule a page out; undefined when they | |
| 84 | * allow it or the printer does not say. | |
| 85 | */ | |
| 86 | export function unsupportedBy( | |
| 87 | page: PwgPage, | |
| 88 | resolutions: readonly [number, number][] | undefined, | |
| 89 | types: readonly string[] | undefined, | |
| 90 | ): "raster_resolution_refused" | "raster_type_refused" | undefined { | |
| 91 | if (resolutions && resolutions.length > 0 && !resolutions.some(([x, y]) => x === page.dpi[0] && y === page.dpi[1])) return "raster_resolution_refused"; | |
| 92 | if (types && types.length > 0 && !types.includes(page.type)) return "raster_type_refused"; | |
| 93 | return undefined; | |
| 94 | } |
src/http/api/print.ts +11 −3
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * `POST /api/v1/devices/:id/print` — an app prints a PDF on a device its |
| 3 | 3 | * owner granted it. The credential is the app's own token; the body is the |
| 4 | * PDF. The route takes a device number and nothing that names an address. | |
| 4 | * PDF, or PWG raster when the content-type says `image/pwg-raster`. The route takes a device number and nothing that names an address. | |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, releasePermit, type PrintError } from "../../devices/print.ts"; |
| 8 | import { PWG_MAX_BYTES } from "../../devices/pwg.ts"; | |
| 8 | 9 | import type { Handler } from "../context.ts"; |
| 9 | 10 | import { readBounded } from "../limits.ts"; |
| 10 | 11 | import { apiError, json, tryAgain, unauthorized, untilNextDay, WINDOW } from "../respond.ts"; |
| @@ -21,6 +22,11 @@ const STATUS: Record<PrintError, number> = { | ||
| 21 | 22 | pages_unknown: 422, |
| 22 | 23 | printer_unreachable: 502, |
| 23 | 24 | printer_refused: 502, |
| 25 | not_raster: 422, | |
| 26 | raster_truncated: 422, | |
| 27 | raster_unsupported: 422, | |
| 28 | raster_resolution_refused: 422, | |
| 29 | raster_type_refused: 422, | |
| 24 | 30 | }; |
| 25 | 31 | |
| 26 | 32 | /** A print refusal; the two 429s say when: the minute's window, or the next UTC day for the daily cap. */ |
| @@ -39,9 +45,11 @@ export function printRoutes(router: Router<Handler>): void { | ||
| 39 | 45 | const permit = permitPrint(ctx.app, secret, id); |
| 40 | 46 | if (!permit.ok) return printRefusal(permit.error); |
| 41 | 47 | // The permit holds a place until printFor, or here when the body never gets that far. |
| 48 | const raster = (ctx.request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase() === "image/pwg-raster"; | |
| 49 | const cap = raster ? PWG_MAX_BYTES : PDF_MAX_BYTES; | |
| 42 | 50 | let pdf: Awaited<ReturnType<typeof readBounded>>; |
| 43 | 51 | try { |
| 44 | pdf = await readBounded(ctx.request, PDF_MAX_BYTES); | |
| 52 | pdf = await readBounded(ctx.request, cap); | |
| 45 | 53 | } catch (e) { |
| 46 | 54 | releasePermit(permit.value); |
| 47 | 55 | throw e; |
| @@ -50,7 +58,7 @@ export function printRoutes(router: Router<Handler>): void { | ||
| 50 | 58 | releasePermit(permit.value); |
| 51 | 59 | return apiError("too_large", 422, PRINT_WORDS.too_large); |
| 52 | 60 | } |
| 53 | const done = await printFor(ctx.app, permit.value, pdf); | |
| 61 | const done = await printFor(ctx.app, permit.value, pdf, raster ? "raster" : "pdf"); | |
| 54 | 62 | if (!done.ok) return printRefusal(done.error); |
| 55 | 63 | return json({ job: { id: done.value.id, state: done.value.state, reasons: done.value.reasons } }); |
| 56 | 64 | }); |
test/print_raster.test.ts added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | /** | |
| 2 | * An app prints PWG raster through the print route: checked before the LAN, | |
| 3 | * then sent as image/pwg-raster. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { mintAppToken } from "../src/apps/token.ts"; | |
| 11 | import { grantDevice } from "../src/core/device_grants.ts"; | |
| 12 | import { declareDevice } from "../src/core/devices.ts"; | |
| 13 | import { getUserByName } from "../src/core/users.ts"; | |
| 14 | import { readPwg } from "../src/devices/pwg.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { adminCookie } from "./helpers/admin.ts"; | |
| 17 | ||
| 18 | const enc = new TextEncoder(); | |
| 19 | const attr = (tag: number, name: string, value: string | number | number[]) => { | |
| 20 | const n = enc.encode(name); | |
| 21 | const v = typeof value === "number" ? [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255] : typeof value === "string" ? [...enc.encode(value)] : value; | |
| 22 | return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v]; | |
| 23 | }; | |
| 24 | const be = (n: number) => [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255]; | |
| 25 | ||
| 26 | /** | |
| 27 | * A PWG page: 1796-byte header, then `height` identical white lines as one | |
| 28 | * repeated PackBits line. | |
| 29 | */ | |
| 30 | function page(width = 64, height = 32, dpi = 600, space = 18, bits = 8): Uint8Array { | |
| 31 | const h = new Uint8Array(1796); | |
| 32 | const v = new DataView(h.buffer); | |
| 33 | v.setUint32(276, dpi); v.setUint32(280, dpi); | |
| 34 | v.setUint32(372, width); v.setUint32(376, height); | |
| 35 | v.setUint32(384, bits); | |
| 36 | const channels = space === 18 ? 1 : 3; | |
| 37 | v.setUint32(388, bits * channels); | |
| 38 | const line = Math.ceil((width * bits * channels) / 8); | |
| 39 | v.setUint32(392, line); | |
| 40 | v.setUint32(400, space); | |
| 41 | const pixel = (bits * channels) / 8; | |
| 42 | const data: number[] = [height - 1]; | |
| 43 | let left = width; | |
| 44 | while (left > 0) { | |
| 45 | const run = Math.min(left, 128); | |
| 46 | data.push(run - 1, ...new Array(pixel).fill(0xff)); | |
| 47 | left -= run; | |
| 48 | } | |
| 49 | return Uint8Array.from([...h, ...data]); | |
| 50 | } | |
| 51 | const pwg = (...pages: Uint8Array[]) => Uint8Array.from([...enc.encode("RaS2"), ...pages.flatMap((p) => [...p])]); | |
| 52 | ||
| 53 | let received: Uint8Array[] = []; | |
| 54 | let capabilities: number[] = []; | |
| 55 | const printer = Bun.serve({ | |
| 56 | port: 0, | |
| 57 | hostname: "127.0.0.1", | |
| 58 | async fetch(req) { | |
| 59 | const body = new Uint8Array(await req.arrayBuffer()); | |
| 60 | received.push(body); | |
| 61 | const getAttributes = body[3] === 0x0b; | |
| 62 | return new Response( | |
| 63 | Uint8Array.from([ | |
| 64 | 1, 1, 0, 0, 0, 0, 0, 1, | |
| 65 | 0x01, ...attr(0x47, "attributes-charset", "utf-8"), | |
| 66 | 0x02, ...(getAttributes ? capabilities : [...attr(0x21, "job-id", 7), ...attr(0x23, "job-state", 9)]), | |
| 67 | 0x03, | |
| 68 | ]), | |
| 69 | { headers: { "content-type": "application/ipp" } }, | |
| 70 | ); | |
| 71 | }, | |
| 72 | }); | |
| 73 | ||
| 74 | let dataDir = "", granted = "", deviceId = 0; | |
| 75 | let app: App, servers: Servers; | |
| 76 | const prior = process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 77 | ||
| 78 | beforeAll(async () => { | |
| 79 | process.env.FORGE_DEVICES_ALLOW_LOOPBACK = "1"; | |
| 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-pwg-")); | |
| 81 | app = createApp({ dataDir, port: 0 }); | |
| 82 | servers = await serve(app); | |
| 83 | await adminCookie(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 84 | const owner = getUserByName(app.db, "alice")!; | |
| 85 | app.db.run("PRAGMA foreign_keys = OFF"); | |
| 86 | app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('a1', 'repo-a1', 'paper', 'private')"); | |
| 87 | app.db.run("PRAGMA foreign_keys = ON"); | |
| 88 | const d = declareDevice(app.db, { name: "Brother", kind: "printer", url: `ipp://127.0.0.1:${printer.port}/ipp/print` }); | |
| 89 | if (!d.ok) throw new Error("declare"); | |
| 90 | deviceId = d.value.id; | |
| 91 | expect(grantDevice(app.db, deviceId, "paper", owner.id).ok).toBe(true); | |
| 92 | granted = mintAppToken(app.db, owner.id, "paper", "production").token; | |
| 93 | }); | |
| 94 | afterAll(async () => { | |
| 95 | if (prior === undefined) delete process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 96 | else process.env.FORGE_DEVICES_ALLOW_LOOPBACK = prior; | |
| 97 | printer.stop(true); | |
| 98 | servers.stop(); | |
| 99 | app.close(); | |
| 100 | await rm(dataDir, { recursive: true, force: true }); | |
| 101 | }); | |
| 102 | ||
| 103 | const print = (body: Uint8Array) => | |
| 104 | fetch(`${servers.url}/api/v1/devices/${deviceId}/print`, { | |
| 105 | method: "POST", | |
| 106 | headers: { connection: "close", "content-type": "image/pwg-raster", authorization: `Bearer ${granted}` }, | |
| 107 | body, | |
| 108 | }); | |
| 109 | ||
| 110 | const includes = (hay: Uint8Array, needle: Uint8Array): boolean => Buffer.from(hay).includes(Buffer.from(needle)); | |
| 111 | ||
| 112 | test("a two-page 600 dpi sgray_8 file reaches the printer as image/pwg-raster, byte for byte", async () => { | |
| 113 | capabilities = []; | |
| 114 | received = []; | |
| 115 | const doc = pwg(page(), page()); | |
| 116 | const parsed = readPwg(doc); | |
| 117 | expect(parsed.ok && parsed.value.map((p) => [p.dpi[0], p.type])).toEqual([[600, "sgray_8"], [600, "sgray_8"]]); | |
| 118 | const res = await print(doc); | |
| 119 | expect(res.status).toBe(200); | |
| 120 | expect(await res.json()).toEqual({ job: { id: 7, state: "completed", reasons: [] } }); | |
| 121 | const job = received.find((r) => r[3] === 0x02)!; | |
| 122 | expect(Buffer.from(job).toString("latin1")).toContain("image/pwg-raster"); | |
| 123 | expect(Buffer.from(job).toString("latin1")).not.toContain("application/pdf"); | |
| 124 | expect(job.length).toBeGreaterThan(doc.length); | |
| 125 | expect(includes(job, doc)).toBe(true); | |
| 126 | const row = app.db.query("SELECT pages, outcome FROM print_job ORDER BY rowid DESC LIMIT 1").get(); | |
| 127 | expect(row).toEqual({ pages: 2, outcome: "printed" }); | |
| 128 | }); | |
| 129 | ||
| 130 | test("a truncated header, a wrong sync word and 21 pages are 422 and nothing reaches the device", async () => { | |
| 131 | received = []; | |
| 132 | const good = pwg(page()); | |
| 133 | expect((await print(good.subarray(0, 500))).status).toBe(422); | |
| 134 | expect((await print(good.subarray(0, good.length - 3))).status).toBe(422); | |
| 135 | const wrong = good.slice(); | |
| 136 | wrong.set(enc.encode("RaS3")); | |
| 137 | const r = await print(wrong); | |
| 138 | expect(r.status).toBe(422); | |
| 139 | expect(((await r.json()) as { error: string }).error).toBe("not_raster"); | |
| 140 | const many = await print(pwg(...Array.from({ length: 21 }, () => page(8, 2)))); | |
| 141 | expect(many.status).toBe(422); | |
| 142 | expect(((await many.json()) as { error: string }).error).toBe("too_many_pages"); | |
| 143 | expect(received.length).toBe(0); | |
| 144 | expect((await print(pwg(...Array.from({ length: 20 }, () => page(8, 2))))).status).toBe(200); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a page the printer says it cannot take is refused with a sentence, before the job is sent", async () => { | |
| 148 | capabilities = [ | |
| 149 | ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(300), ...be(300), 3]), | |
| 150 | ...attr(0x44, "pwg-raster-document-type-supported", "srgb_8"), | |
| 151 | ]; | |
| 152 | received = []; | |
| 153 | const r = await print(pwg(page())); | |
| 154 | expect(r.status).toBe(422); | |
| 155 | expect(((await r.json()) as { error: string }).error).toBe("raster_resolution_refused"); | |
| 156 | capabilities = [ | |
| 157 | ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(600), ...be(600), 3]), | |
| 158 | ...attr(0x44, "pwg-raster-document-type-supported", "srgb_8"), | |
| 159 | ]; | |
| 160 | const t = await print(pwg(page())); | |
| 161 | expect(t.status).toBe(422); | |
| 162 | expect(((await t.json()) as { error: string }).error).toBe("raster_type_refused"); | |
| 163 | expect(received.every((x) => x[3] === 0x0b)).toBe(true); | |
| 164 | capabilities = [ | |
| 165 | ...attr(0x32, "pwg-raster-document-resolution-supported", [...be(600), ...be(600), 3]), | |
| 166 | ...attr(0x44, "pwg-raster-document-type-supported", "sgray_8"), | |
| 167 | ]; | |
| 168 | expect((await print(pwg(page()))).status).toBe(200); | |
| 169 | }); | |
| 170 | ||
| 171 | test("PackBits runs that overrun a line or the page are refused", () => { | |
| 172 | const p = page(64, 32); | |
| 173 | const bad = p.slice(); | |
| 174 | bad[1796] = 40; // line repeat past the page height | |
| 175 | expect(readPwg(pwg(bad)).ok).toBe(false); | |
| 176 | expect(readPwg(pwg(page(64, 32, 600, 19, 8))).ok).toBe(true); // srgb_8 | |
| 177 | }); |
new above
offline