Public: anyone can read this; the crew can change it.
An app granted the printer prints a PDF through the forge, and reaches nothing else on the LAN #10671
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10568-an-app-granted-the-printer-prints-a-pdf → main 5 commits fork 3 threads · 3 unresolved
18 files
changed +710
−14
docs/features/devices.md +11
| @@ -8,3 +8,14 @@ | ||
| 8 | 8 | - Declaring and removing append `device.declared` / `device.removed` events. |
| 9 | 9 | - Assumptions: IPP 1.1 over plain HTTP(S) to the address given; supply levels are the percent the printer reports. 0.0.0.0 and 169.254.0.0/16 (cloud metadata) are refused outright. |
| 10 | 10 | - The terminal has the same screen for site admins (palette: Devices): `d` declares, `x` removes after a confirm, through the same actions and events as the page. |
| 11 | ||
| 12 | ## Printing from an app | |
| 13 | ||
| 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. | |
| 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 | - 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 | - Each print appends `device.printed` (device, app, pages). | |
| 19 | - The app and grant are checked before the body is read, so a caller without a grant costs a lookup, not 10 MB. An app may start 10 prints a minute and have 2 in flight; past that, 429 `rate_limited`. | |
| 20 | - A PDF containing an object stream (`/ObjStm`) answers 422 `pages_unknown`: the page tree could hide there behind a plain decoy. | |
| 21 | - A token minted before this feature has no `app_token` row, so a running app gets 403 until it restarts and mints a fresh one. |
src/actions/devices.ts +25 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | 8 | import { |
| 9 | 9 | declareDevice, |
| 10 | getDevice, | |
| 10 | 11 | isFresh, |
| 11 | 12 | keepProbe, |
| 12 | 13 | listDevices, |
| @@ -14,11 +15,12 @@ import { | ||
| 14 | 15 | type Device, |
| 15 | 16 | type DeviceError, |
| 16 | 17 | } from "../core/devices.ts"; |
| 18 | import { grantDevice, revokeDevice } from "../core/device_grants.ts"; | |
| 17 | 19 | import type { User } from "../core/users.ts"; |
| 18 | 20 | import { probePrinter, type PrinterReport } from "../devices/ipp.ts"; |
| 19 | 21 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 20 | 22 | |
| 21 | export type DeviceActionError = DeviceError | "forbidden"; | |
| 23 | export type DeviceActionError = DeviceError | "forbidden" | "no_such_app"; | |
| 22 | 24 | |
| 23 | 25 | export const DEVICE_ERRORS: Record<DeviceActionError, string> = { |
| 24 | 26 | invalid_name: "A name is letters, digits, spaces, dots, dashes or underscores, up to 60.", |
| @@ -27,6 +29,7 @@ export const DEVICE_ERRORS: Record<DeviceActionError, string> = { | ||
| 27 | 29 | public_address: "The address must be a number on your own network (like 192.168.1.161). The forge does not ask public hosts or look up names.", |
| 28 | 30 | name_taken: "A device already has that name.", |
| 29 | 31 | not_found: "No such device.", |
| 32 | no_such_app: "No such app, or nothing to change for it.", | |
| 30 | 33 | forbidden: "Only a site admin manages devices.", |
| 31 | 34 | }; |
| 32 | 35 | |
| @@ -58,6 +61,27 @@ export function remove(app: App, actor: User | undefined, id: number): Result<tr | ||
| 58 | 61 | return gone; |
| 59 | 62 | } |
| 60 | 63 | |
| 64 | /** The owner lets one app print on one device; the app is named by slug. */ | |
| 65 | export function grant(app: App, actor: User | undefined, id: number, slug: string): Result<string, DeviceActionError> { | |
| 66 | if (!mayManageDevices(actor)) return err("forbidden"); | |
| 67 | const device = Number.isSafeInteger(id) ? getDevice(app.db, id) : undefined; | |
| 68 | if (!device) return err("not_found"); | |
| 69 | const made = grantDevice(app.db, id, slug, actor.id); | |
| 70 | if (!made.ok) return err("no_such_app"); | |
| 71 | app.bus.append({ kind: "device.granted", actorId: actor.id, payload: { device: device.name, app: made.value.slug } }); | |
| 72 | return ok(made.value.slug); | |
| 73 | } | |
| 74 | ||
| 75 | export function revoke(app: App, actor: User | undefined, id: number, slug: string): Result<string, DeviceActionError> { | |
| 76 | if (!mayManageDevices(actor)) return err("forbidden"); | |
| 77 | const device = Number.isSafeInteger(id) ? getDevice(app.db, id) : undefined; | |
| 78 | if (!device) return err("not_found"); | |
| 79 | const gone = revokeDevice(app.db, id, slug); | |
| 80 | if (!gone.ok) return err("no_such_app"); | |
| 81 | app.bus.append({ kind: "device.revoked", actorId: actor.id, payload: { device: device.name, app: gone.value.slug } }); | |
| 82 | return ok(gone.value.slug); | |
| 83 | } | |
| 84 | ||
| 61 | 85 | /** The devices, each stale printer asked at once within the probe's own bound; a fresh one is not asked. */ |
| 62 | 86 | export async function devicesWithStatus(app: App): Promise<Device[]> { |
| 63 | 87 | return Promise.all( |
src/apps/token.ts +7 −1
| @@ -41,9 +41,15 @@ export function mintAppToken( | ||
| 41 | 41 | env: string, |
| 42 | 42 | now: number = Date.now(), |
| 43 | 43 | ): AppToken { |
| 44 | return createToken(db, ownerId, appTokenName(slug, env), "ro", { | |
| 44 | const token = createToken(db, ownerId, appTokenName(slug, env), "ro", { | |
| 45 | 45 | expiresAt: until(now), |
| 46 | 46 | }); |
| 47 | // Which app holds it: what the forge trusts when the process asks for a | |
| 48 | // device (`core/device_grants.ts`), because only this function writes it. | |
| 49 | db.query( | |
| 50 | "INSERT INTO app_token (token_id, app_id) SELECT ?1, id FROM app WHERE slug = ?2", | |
| 51 | ).run(token.id, slug); | |
| 52 | return token; | |
| 47 | 53 | } |
| 48 | 54 | |
| 49 | 55 | export function renewAppToken( |
src/contract/openapi/routes/index.ts +1
| @@ -255,6 +255,7 @@ export { route as postApiV1ConversationMessages } from "./postApiV1ConversationM | ||
| 255 | 255 | export { route as postApiV1ConversationStop } from "./postApiV1ConversationStop.ts"; |
| 256 | 256 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; |
| 257 | 257 | export { route as postApiV1Conversations } from "./postApiV1Conversations.ts"; |
| 258 | export { route as postApiV1DevicesIdPrint } from "./postApiV1DevicesIdPrint.ts"; | |
| 258 | 259 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 259 | 260 | export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts"; |
| 260 | 261 | export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts"; |
src/contract/openapi/routes/postApiV1DevicesIdPrint.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 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", | |
| 7 | auth: true, | |
| 8 | refusals: { | |
| 9 | forbidden: "403: the app holds no grant on this device", | |
| 10 | rate_limited: "429: the app started more than 10 prints in a minute, or has 2 in flight", | |
| 11 | too_large: "422: the PDF is over the size bound", | |
| 12 | not_pdf: "422: the body is not a PDF", | |
| 13 | too_many_pages: "422: the PDF has more pages than the grant allows", | |
| 14 | pages_unknown: "422: the page count could not be read", | |
| 15 | printer_unreachable: "502: the printer did not answer", | |
| 16 | printer_refused: "502: the printer refused the job", | |
| 17 | unauthorized: "no token", | |
| 18 | }, | |
| 19 | }; |
src/core/device_grants.ts added +60
| @@ -0,0 +1,60 @@ | ||
| 1 | /** | |
| 2 | * Which apps may use which device. The owner grants one declared device to | |
| 3 | * one app; an app is known by the credential its process holds (`app_token`), | |
| 4 | * never by a name or an address it sends. No grant, no print. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import { sha256 } from "../kernel/hash.ts"; | |
| 9 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 10 | ||
| 11 | export interface AppRef { | |
| 12 | readonly id: string; | |
| 13 | readonly slug: string; | |
| 14 | } | |
| 15 | ||
| 16 | export type GrantError = "not_found"; | |
| 17 | ||
| 18 | /** The app whose running process holds this token secret, or undefined. */ | |
| 19 | export function appOfToken(db: Database, secret: string): AppRef | undefined { | |
| 20 | return ( | |
| 21 | db | |
| 22 | .query<AppRef, [string]>( | |
| 23 | `SELECT a.id, a.slug FROM token t | |
| 24 | JOIN app_token x ON x.token_id = t.id | |
| 25 | JOIN app a ON a.id = x.app_id | |
| 26 | WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, | |
| 27 | ) | |
| 28 | .get(sha256(secret)) ?? undefined | |
| 29 | ); | |
| 30 | } | |
| 31 | ||
| 32 | export const isGranted = (db: Database, deviceId: number, appId: string): boolean => | |
| 33 | db.query("SELECT 1 FROM device_grant WHERE device_id = ?1 AND app_id = ?2").get(deviceId, appId) !== null; | |
| 34 | ||
| 35 | export function grantDevice(db: Database, deviceId: number, slug: string, by: string): Result<AppRef, GrantError> { | |
| 36 | const app = db.query<AppRef, [string]>("SELECT id, slug FROM app WHERE slug = ?1").get(slug); | |
| 37 | const device = db.query("SELECT 1 FROM device WHERE id = ?1").get(deviceId); | |
| 38 | if (!app || !device) return err("not_found"); | |
| 39 | db.query("INSERT OR IGNORE INTO device_grant (device_id, app_id, granted_by) VALUES (?1, ?2, ?3)").run(deviceId, app.id, by); | |
| 40 | return ok(app); | |
| 41 | } | |
| 42 | ||
| 43 | export function revokeDevice(db: Database, deviceId: number, slug: string): Result<AppRef, GrantError> { | |
| 44 | const app = db.query<AppRef, [string]>("SELECT id, slug FROM app WHERE slug = ?1").get(slug); | |
| 45 | if (!app) return err("not_found"); | |
| 46 | const gone = db.query("DELETE FROM device_grant WHERE device_id = ?1 AND app_id = ?2").run(deviceId, app.id); | |
| 47 | return gone.changes === 0 ? err("not_found") : ok(app); | |
| 48 | } | |
| 49 | ||
| 50 | /** Slugs of the apps granted this device, and every app slug for the picker. */ | |
| 51 | export const grantedSlugs = (db: Database, deviceId: number): string[] => | |
| 52 | db | |
| 53 | .query<{ slug: string }, [number]>( | |
| 54 | "SELECT a.slug FROM device_grant g JOIN app a ON a.id = g.app_id WHERE g.device_id = ?1 ORDER BY a.slug", | |
| 55 | ) | |
| 56 | .all(deviceId) | |
| 57 | .map((r) => r.slug); | |
| 58 | ||
| 59 | export const allAppSlugs = (db: Database): string[] => | |
| 60 | db.query<{ slug: string }, []>("SELECT slug FROM app ORDER BY slug").all().map((r) => r.slug); |
src/core/migration-pins/20261110120000-device-grants.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | dfadb8e0cefb29a13678e3273325a59aa53a5ec2c0ff6aac56e78cb37d4c005a |
src/core/migrations/20261110120000-device-grants.ts added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | // An app granted a device may print on it through the forge (#10568). | |
| 2 | // `device_grant` is the owner's yes, one row per (device, app); `app_token` | |
| 3 | // says which app a running process's token belongs to, so the forge knows who | |
| 4 | // is asking from the credential and not from a name anyone could choose. | |
| 5 | // Both rows go with the device, the app or the token. Additive. | |
| 6 | export default ` | |
| 7 | CREATE TABLE device_grant ( | |
| 8 | device_id INTEGER NOT NULL REFERENCES device(id) ON DELETE CASCADE, | |
| 9 | app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE, | |
| 10 | granted_by TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 11 | granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 12 | PRIMARY KEY (device_id, app_id) | |
| 13 | ); | |
| 14 | CREATE TABLE app_token ( | |
| 15 | token_id TEXT PRIMARY KEY REFERENCES token(id) ON DELETE CASCADE, | |
| 16 | app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE | |
| 17 | ); | |
| 18 | `; |
src/devices/ipp.ts +75 −2
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * touches the network, to the one validated URL it is given. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | const OP_PRINT_JOB = 0x0002; // RFC 8011 §4.2.1 | |
| 8 | 9 | const OP_GET_PRINTER_ATTRIBUTES = 0x000b; // RFC 8011 §5.2 operation-id registry |
| 9 | 10 | const TAG = { |
| 10 | 11 | operation: 0x01, |
| @@ -13,6 +14,8 @@ const TAG = { | ||
| 13 | 14 | boolean: 0x22, |
| 14 | 15 | enum: 0x23, |
| 15 | 16 | keyword: 0x44, |
| 17 | mimeType: 0x49, | |
| 18 | nameWithoutLanguage: 0x42, | |
| 16 | 19 | uri: 0x45, |
| 17 | 20 | charset: 0x47, |
| 18 | 21 | language: 0x48, |
| @@ -158,6 +161,17 @@ export type ProbeResult = | ||
| 158 | 161 | * the wait bounded. |
| 159 | 162 | */ |
| 160 | 163 | export async function probePrinter(url: string, timeoutMs = 2500): Promise<ProbeResult> { |
| 164 | const answer = await exchange(url, encodeGetPrinterAttributes(url), timeoutMs); | |
| 165 | if (!answer.ok) return answer; | |
| 166 | return { ok: true, report: reportOf(answer.response.attributes) }; | |
| 167 | } | |
| 168 | ||
| 169 | type Exchange = | |
| 170 | | { readonly ok: true; readonly response: IppResponse } | |
| 171 | | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" }; | |
| 172 | ||
| 173 | /** One IPP request to `url` and its decoded, successful answer. */ | |
| 174 | async function exchange(url: string, body: Uint8Array, timeoutMs: number): Promise<Exchange> { | |
| 161 | 175 | const parsed = URL.parse(url); |
| 162 | 176 | if (parsed === null) return { ok: false, error: "bad_answer" }; |
| 163 | 177 | const secure = parsed.protocol === "ipps:" || parsed.protocol === "https:"; |
| @@ -169,7 +183,7 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe | ||
| 169 | 183 | const res = await fetch(wire, { |
| 170 | 184 | method: "POST", |
| 171 | 185 | headers: { "content-type": "application/ipp" }, |
| 172 | body: encodeGetPrinterAttributes(url), | |
| 186 | body, | |
| 173 | 187 | redirect: "error", |
| 174 | 188 | signal: AbortSignal.timeout(timeoutMs), |
| 175 | 189 | }); |
| @@ -179,12 +193,71 @@ export async function probePrinter(url: string, timeoutMs = 2500): Promise<Probe | ||
| 179 | 193 | const decoded = decodeResponse(bytes); |
| 180 | 194 | // 0x0000–0x00ff: successful (RFC 8011 §5.4.1). |
| 181 | 195 | if (decoded === undefined || decoded.status > 0xff) return { ok: false, error: "bad_answer" }; |
| 182 | return { ok: true, report: reportOf(decoded.attributes) }; | |
| 196 | return { ok: true, response: decoded }; | |
| 183 | 197 | } catch { |
| 184 | 198 | return { ok: false, error: "unreachable" }; |
| 185 | 199 | } |
| 186 | 200 | } |
| 187 | 201 | |
| 202 | /** The request body for Print-Job: the document follows the attributes. */ | |
| 203 | export function encodePrintJob(printerUri: string, pdf: Uint8Array, jobName: string, requestId = 1): Uint8Array { | |
| 204 | const head: number[] = [ | |
| 205 | 1, 1, | |
| 206 | OP_PRINT_JOB >> 8, OP_PRINT_JOB & 255, | |
| 207 | (requestId >>> 24) & 255, (requestId >>> 16) & 255, (requestId >>> 8) & 255, requestId & 255, | |
| 208 | TAG.operation, | |
| 209 | ...attr(TAG.charset, "attributes-charset", "utf-8"), | |
| 210 | ...attr(TAG.language, "attributes-natural-language", "en"), | |
| 211 | ...attr(TAG.uri, "printer-uri", printerUri), | |
| 212 | ...attr(TAG.nameWithoutLanguage, "requesting-user-name", "forge"), | |
| 213 | ...attr(TAG.nameWithoutLanguage, "job-name", jobName), | |
| 214 | ...attr(TAG.mimeType, "document-format", "application/pdf"), | |
| 215 | TAG.end, | |
| 216 | ]; | |
| 217 | const out = new Uint8Array(head.length + pdf.length); | |
| 218 | out.set(head); | |
| 219 | out.set(pdf, head.length); | |
| 220 | return out; | |
| 221 | } | |
| 222 | ||
| 223 | export type JobState = "pending" | "held" | "processing" | "stopped" | "canceled" | "aborted" | "completed" | "unknown"; | |
| 224 | ||
| 225 | /** job-state enum (RFC 8011 §5.3.7). */ | |
| 226 | const JOB_STATES: Record<number, JobState> = { | |
| 227 | 3: "pending", 4: "held", 5: "processing", 6: "stopped", 7: "canceled", 8: "aborted", 9: "completed", | |
| 228 | }; | |
| 229 | ||
| 230 | export interface PrintJob { | |
| 231 | readonly id: number | null; | |
| 232 | readonly state: JobState; | |
| 233 | readonly reasons: readonly string[]; | |
| 234 | } | |
| 235 | ||
| 236 | export type PrintResult = | |
| 237 | | { readonly ok: true; readonly job: PrintJob } | |
| 238 | | { readonly ok: false; readonly error: "unreachable" | "refused" | "bad_answer" }; | |
| 239 | ||
| 240 | /** | |
| 241 | * Send `pdf` to the printer at `url` (already validated and granted) as an | |
| 242 | * IPP Print-Job and return the job state the printer reports. A printer that | |
| 243 | * takes a long document needs longer than a probe does. | |
| 244 | */ | |
| 245 | 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); | |
| 247 | if (!answer.ok) return answer; | |
| 248 | const a = answer.response.attributes; | |
| 249 | const id = a.get("job-id")?.[0]; | |
| 250 | const state = a.get("job-state")?.[0]; | |
| 251 | return { | |
| 252 | ok: true, | |
| 253 | job: { | |
| 254 | id: typeof id === "number" ? id : null, | |
| 255 | state: typeof state === "number" ? (JOB_STATES[state] ?? "unknown") : "unknown", | |
| 256 | reasons: str(a, "job-state-reasons").filter((r) => r !== "none"), | |
| 257 | }, | |
| 258 | }; | |
| 259 | } | |
| 260 | ||
| 188 | 261 | async function readCapped(res: Response): Promise<Uint8Array | undefined> { |
| 189 | 262 | const reader = res.body?.getReader(); |
| 190 | 263 | if (!reader) return undefined; |
src/devices/print.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * An app printing through the forge. The app holds only its own token; the | |
| 3 | * forge decides: the token names the app, the owner's grant names the device, | |
| 4 | * and the address is the one declared, never one the request carries. The | |
| 5 | * document is checked here, before any byte leaves for the LAN. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { appOfToken, isGranted, type AppRef } from "../core/device_grants.ts"; | |
| 10 | import { getDevice, type Device } from "../core/devices.ts"; | |
| 11 | import { RateLimiter } from "../core/ratelimit.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import { printPdf, type PrintJob } from "./ipp.ts"; | |
| 14 | ||
| 15 | export const PDF_MAX_BYTES = 10 * 1024 * 1024; | |
| 16 | export const PDF_MAX_PAGES = 20; | |
| 17 | ||
| 18 | /** Prints one app may start in a minute, and at once: each holds the forge up to the printer's own bound. */ | |
| 19 | export const PRINTS_PER_MINUTE = 10; | |
| 20 | export const PRINTS_AT_ONCE = 2; | |
| 21 | ||
| 22 | export type PrintError = "forbidden" | "rate_limited" | "not_pdf" | "too_large" | "too_many_pages" | "pages_unknown" | "printer_unreachable" | "printer_refused"; | |
| 23 | ||
| 24 | export const PRINT_WORDS: Record<PrintError, string> = { | |
| 25 | forbidden: "This app is not granted that printer.", | |
| 26 | rate_limited: "This app is printing too fast; wait a little.", | |
| 27 | not_pdf: "The body must be a PDF (application/pdf).", | |
| 28 | too_large: "The PDF is over 10 MB.", | |
| 29 | too_many_pages: "The PDF is over 20 pages.", | |
| 30 | pages_unknown: "The page count could not be read; save the PDF without compressed object streams.", | |
| 31 | printer_unreachable: "The printer did not answer.", | |
| 32 | printer_refused: "The printer did not accept the job.", | |
| 33 | }; | |
| 34 | ||
| 35 | const latin = { | |
| 36 | decode: (b: Uint8Array): string => Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString("latin1"), | |
| 37 | }; | |
| 38 | ||
| 39 | /** | |
| 40 | * The page count of a PDF read from its plain page tree: the largest `/Count` | |
| 41 | * of a `/Type /Pages` node, or the number of `/Type /Page` leaves, whichever is | |
| 42 | * greater. Zero when the file has an object stream (or a type name written | |
| 43 | * with `#` escapes), since the real tree could sit there while a plain | |
| 44 | * decoy node says `/Count 1`. | |
| 45 | */ | |
| 46 | export function pageCount(pdf: Uint8Array): number { | |
| 47 | const text = latin.decode(pdf); | |
| 48 | if (/\/ObjStm|\/Type\s*\/\w*#/.test(text)) return 0; | |
| 49 | const counted = [...text.matchAll(/\/Type\s*\/Page(?![A-Za-z])/g)].length; | |
| 50 | let declared = 0; | |
| 51 | for (const m of text.matchAll(/\/Type\s*\/Pages(?![A-Za-z])/g)) { | |
| 52 | const near = text.slice(Math.max(0, m.index - 300), m.index + 300); | |
| 53 | for (const c of near.matchAll(/\/Count\s+(\d+)/g)) declared = Math.max(declared, Number(c[1])); | |
| 54 | } | |
| 55 | return Math.max(counted, declared); | |
| 56 | } | |
| 57 | ||
| 58 | /** The body is a PDF within the limits, or why not. */ | |
| 59 | export function checkPdf(pdf: Uint8Array): Result<number, PrintError> { | |
| 60 | if (pdf.length > PDF_MAX_BYTES) return err("too_large"); | |
| 61 | const tail = latin.decode(pdf.subarray(Math.max(0, pdf.length - 1024))); | |
| 62 | if (pdf.length < 8 || latin.decode(pdf.subarray(0, 5)) !== "%PDF-" || !tail.includes("%%EOF")) return err("not_pdf"); | |
| 63 | const pages = pageCount(pdf); | |
| 64 | if (pages === 0) return err("pages_unknown"); | |
| 65 | if (pages > PDF_MAX_PAGES) return err("too_many_pages"); | |
| 66 | return ok(pages); | |
| 67 | } | |
| 68 | ||
| 69 | const perMinute = new RateLimiter({ perMinute: PRINTS_PER_MINUTE, adminPerMinute: PRINTS_PER_MINUTE, enabled: true }); | |
| 70 | const inFlight = new Map<string, number>(); | |
| 71 | ||
| 72 | /** Who is printing and where, decided from the token and the grant alone. */ | |
| 73 | export interface Permit { | |
| 74 | readonly caller: AppRef; | |
| 75 | readonly device: Device; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * The app holding `secret` and the device it is granted, or `forbidden` | |
| 80 | * (so a caller learns nothing about devices it was not given), or | |
| 81 | * `rate_limited`. Nothing of the body has been read yet: an unknown or | |
| 82 | * ungranted caller costs a lookup. | |
| 83 | */ | |
| 84 | export function permitPrint(app: App, secret: string, deviceId: number): Result<Permit, PrintError> { | |
| 85 | const caller = appOfToken(app.db, secret); | |
| 86 | if (!caller || !Number.isSafeInteger(deviceId)) return err("forbidden"); | |
| 87 | const device = getDevice(app.db, deviceId); | |
| 88 | if (!device || device.kind !== "printer" || !isGranted(app.db, device.id, caller.id)) return err("forbidden"); | |
| 89 | if ((inFlight.get(caller.id) ?? 0) >= PRINTS_AT_ONCE || !perMinute.take(caller.id, "ro").allowed) return err("rate_limited"); | |
| 90 | return ok({ caller, device }); | |
| 91 | } | |
| 92 | ||
| 93 | /** Print `pdf` for a permitted app; the document is checked before any byte leaves for the LAN. */ | |
| 94 | export async function printFor(app: App, permit: Permit, pdf: Uint8Array): Promise<Result<PrintJob, PrintError>> { | |
| 95 | const { caller, device } = permit; | |
| 96 | const checked = checkPdf(pdf); | |
| 97 | if (!checked.ok) return checked; | |
| 98 | inFlight.set(caller.id, (inFlight.get(caller.id) ?? 0) + 1); | |
| 99 | const sent = await printPdf(device.url, pdf, `${caller.slug} (forge)`).finally(() => { | |
| 100 | const left = (inFlight.get(caller.id) ?? 1) - 1; | |
| 101 | if (left <= 0) inFlight.delete(caller.id); | |
| 102 | else inFlight.set(caller.id, left); | |
| 103 | }); | |
| 104 | if (sent.ok) { | |
| 105 | app.bus.append({ kind: "device.printed", payload: { device: device.name, app: caller.slug, pages: checked.value } }); | |
| 106 | return ok(sent.job); | |
| 107 | } | |
| 108 | return err(sent.error === "unreachable" ? "printer_unreachable" : "printer_refused"); | |
| 109 | } |
src/http/api.ts +2
| @@ -340,6 +340,7 @@ import { frontInput, frontJson, setFront } from "../actions/front.ts"; | ||
| 340 | 340 | import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts"; |
| 341 | 341 | import { passportRoutes } from "./passport_edge.ts"; |
| 342 | 342 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 343 | import { printRoutes } from "./api/print.ts"; | |
| 343 | 344 | import { forgeRoutes } from "./api/forges.ts"; |
| 344 | 345 | import { forgeViewRoutes } from "./api/forge_view.ts"; |
| 345 | 346 | import { economicsRoutes } from "./api/economics.ts"; |
| @@ -3763,6 +3764,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3763 | 3764 | iterationRoutes(router); |
| 3764 | 3765 | |
| 3765 | 3766 | egressGrantRoutes(router); |
| 3767 | printRoutes(router); | |
| 3766 | 3768 | |
| 3767 | 3769 | promptRoutes(router); |
| 3768 | 3770 |
src/http/api/print.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * `POST /api/v1/devices/:id/print` — an app prints a PDF on a device its | |
| 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. | |
| 5 | */ | |
| 6 | ||
| 7 | import { PRINT_WORDS, PDF_MAX_BYTES, permitPrint, printFor, type PrintError } from "../../devices/print.ts"; | |
| 8 | import type { Handler } from "../context.ts"; | |
| 9 | import { readBounded } from "../limits.ts"; | |
| 10 | import { apiError, json, unauthorized } from "../respond.ts"; | |
| 11 | import type { Router } from "../router.ts"; | |
| 12 | import { tokenSecret } from "../token_header.ts"; | |
| 13 | ||
| 14 | const STATUS: Record<PrintError, number> = { | |
| 15 | forbidden: 403, | |
| 16 | rate_limited: 429, | |
| 17 | not_pdf: 422, | |
| 18 | too_large: 422, | |
| 19 | too_many_pages: 422, | |
| 20 | pages_unknown: 422, | |
| 21 | printer_unreachable: 502, | |
| 22 | printer_refused: 502, | |
| 23 | }; | |
| 24 | ||
| 25 | export function printRoutes(router: Router<Handler>): void { | |
| 26 | router.add("POST", "/api/v1/devices/:id/print", async (ctx) => { | |
| 27 | const secret = tokenSecret(ctx.request.headers.get("authorization") ?? ""); | |
| 28 | if (secret === null) return unauthorized(); | |
outdated
High: the body is read (up to 10 MB) before the caller is verified; any bearer string reaches this. Verify the token and the grant first, then read. Add a rate limit per app id. |
||
| 29 | const id = /^\d{1,9}$/.test(ctx.params["id"] ?? "") ? Number(ctx.params["id"]) : Number.NaN; | |
| 30 | // Who and where first; the body (up to 10 MB) is read only for a granted app. | |
| 31 | const permit = permitPrint(ctx.app, secret, id); | |
| 32 | if (!permit.ok) return apiError(permit.error, STATUS[permit.error], PRINT_WORDS[permit.error]); | |
| 33 | const pdf = await readBounded(ctx.request, PDF_MAX_BYTES); | |
| 34 | if (pdf === "too_large") return apiError("too_large", 422, PRINT_WORDS.too_large); | |
| 35 | const done = await printFor(ctx.app, permit.value, pdf); | |
| 36 | if (!done.ok) return apiError(done.error, STATUS[done.error], PRINT_WORDS[done.error]); | |
| 37 | return json({ job: { id: done.value.id, state: done.value.state, reasons: done.value.reasons } }); | |
| 38 | }); | |
| 39 | } |
src/http/settings_routes.ts +3 −1
| @@ -38,7 +38,7 @@ import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; | ||
| 38 | 38 | import { packsSettingsPage, packsSettingsSave } from "../web/pages/packs-settings.ts"; |
| 39 | 39 | import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; |
| 40 | 40 | import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts"; |
| 41 | import { deviceDeclare, devicesPage, deviceRemove } from "../web/settings/devices.ts"; | |
| 41 | import { deviceDeclare, deviceGrant, devicesPage, deviceRemove, deviceRevoke } from "../web/settings/devices.ts"; | |
| 42 | 42 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 43 | 43 | import { |
| 44 | 44 | backupsClear, |
| @@ -182,6 +182,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 182 | 182 | router.add("GET", "/settings/devices", devicesPage); |
| 183 | 183 | router.add("POST", "/settings/devices", deviceDeclare); |
| 184 | 184 | router.add("POST", "/settings/devices/delete", deviceRemove); |
| 185 | router.add("POST", "/settings/devices/grant", deviceGrant); | |
| 186 | router.add("POST", "/settings/devices/revoke", deviceRevoke); | |
| 185 | 187 | router.add("GET", "/settings/cloud", cloudPage); |
| 186 | 188 | router.add("POST", "/settings/cloud", cloudAdd); |
| 187 | 189 | router.add("POST", "/settings/cloud/verify", cloudVerify); |
src/tui/screens/devices.ts +30 −4
| @@ -3,11 +3,14 @@ | ||
| 3 | 3 | * Devices lists, each with its model, state and supplies on one line. `d` |
| 4 | 4 | * declares one (a name, then its address, in one line); `x` removes the one |
| 5 | 5 | * under the cursor after a confirm. Both go through `actions/devices.ts`, so |
| 6 | * the refusals and the `device.*` events are the page's. Anyone who is not an | |
| 6 | * the refusals and the `device.*` events are the page's. `a` allows the one | |
| 7 | * under the cursor to an app (its slug, in one line) and `r` revokes one | |
| 8 | * (likewise): who may print there. Anyone who is not an | |
| 7 | 9 | * admin (or is an agent) is offered no entry and changes nothing. |
| 8 | 10 | */ |
| 9 | 11 | |
| 10 | import { DEVICE_ERRORS, declare, devicesWithStatus, mayManageDevices, remove, statusLine } from "../../actions/devices.ts"; | |
| 12 | import { DEVICE_ERRORS, declare, devicesWithStatus, grant, mayManageDevices, remove, revoke, statusLine } from "../../actions/devices.ts"; | |
| 13 | import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts"; | |
| 11 | 14 | import { listDevices, type Device } from "../../core/devices.ts"; |
| 12 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 16 | import type { PaletteEntry } from "./entries.ts"; |
| @@ -54,6 +57,20 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 54 | 57 | placeholder: "Hall printer ipp://192.168.1.161:631/ipp/print", |
| 55 | 58 | onSubmit: declareOne, |
| 56 | 59 | }); |
| 60 | const change = (d: Device, act: typeof grant, done: string) => (line: string): string | undefined => { | |
| 61 | const slug = line.trim(); | |
| 62 | const made = act(f.app, f.user, d.id, slug); | |
| 63 | note = made.ok ? `${made.value} ${done} ${d.name}.` : DEVICE_ERRORS[made.error]; | |
| 64 | if (made.ok) reload(); | |
| 65 | return made.ok ? undefined : note; | |
| 66 | }; | |
| 67 | const askApp = (d: Device, act: typeof grant, title: string, done: string): Screen => | |
| 68 | promptScreen(ctx, screen, { | |
| 69 | title, | |
| 70 | label: `App slug for ${d.name}`, | |
| 71 | placeholder: allAppSlugs(f.db).join(" ") || "no apps yet", | |
| 72 | onSubmit: change(d, act, done), | |
| 73 | }); | |
| 57 | 74 | const confirmRemove = (d: Device): Screen => |
| 58 | 75 | confirmScreen(ctx, screen, { |
| 59 | 76 | title: "Remove this device", |
| @@ -67,10 +84,14 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 67 | 84 | }, |
| 68 | 85 | }); |
| 69 | 86 | |
| 87 | const apps = (d: Device): string => { | |
| 88 | const slugs = grantedSlugs(f.db, d.id); | |
| 89 | return slugs.length === 0 ? "" : ` · apps: ${slugs.join(", ")}`; | |
| 90 | }; | |
| 70 | 91 | const lines = (): ListRow[] => |
| 71 | 92 | rows.map((d) => ({ |
| 72 | 93 | title: d.name, |
| 73 | meta: d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`, | |
| 94 | meta: `${d.probedAt === null ? d.url : `${statusLine(d.probe)} · ${d.url}`}${apps(d)}`, | |
| 74 | 95 | ...(d.probe?.supplies.length |
| 75 | 96 | ? { below: [d.probe.supplies.map((s) => `${s.name} ${s.percent === null ? "?" : `${s.percent}%`}`).join(" · ")] } |
| 76 | 97 | : {}), |
| @@ -78,7 +99,7 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 78 | 99 | |
| 79 | 100 | const screen: Screen = { |
| 80 | 101 | title: "Devices", |
| 81 | keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["j", "k"], "move cursor")], | |
| 102 | keys: [binding(["d"], "declare"), binding(["x"], "remove"), binding(["a"], "allow an app"), binding(["r"], "revoke an app"), binding(["j", "k"], "move cursor")], | |
| 82 | 103 | render(area, s) { |
| 83 | 104 | const c = ctx.theme.c; |
| 84 | 105 | const text = note !== "" ? note : mayUse(f) ? `${rows.length} declared.` : DEVICE_ERRORS.forbidden; |
| @@ -96,6 +117,11 @@ export function devicesScreen(f: Session, ctx: Ctx): Screen { | ||
| 96 | 117 | return true; |
| 97 | 118 | } |
| 98 | 119 | const d = rows[state.index]; |
| 120 | if ((e.key === "a" || e.key === "r") && d !== undefined) { | |
| 121 | note = ""; | |
| 122 | ctx.push(e.key === "a" ? askApp(d, grant, "Let an app print here", "may print on") : askApp(d, revoke, "Stop an app printing here", "may no longer print on")); | |
| 123 | return true; | |
| 124 | } | |
| 99 | 125 | if (e.key === "x" && d !== undefined) { |
| 100 | 126 | note = ""; |
| 101 | 127 | ctx.push(confirmRemove(d)); |
src/web/settings/devices.ts +48 −3
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Ctx } from "../../http/context.ts"; |
| 10 | import { DEVICE_ERRORS, declare, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts"; | |
| 10 | import { DEVICE_ERRORS, declare, grant, revoke, devicesWithStatus, remove, statusLine } from "../../actions/devices.ts"; | |
| 11 | import { allAppSlugs, grantedSlugs } from "../../core/device_grants.ts"; | |
| 11 | 12 | import { NAME_MAX, URL_MAX, type Device } from "../../core/devices.ts"; |
| 12 | 13 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 13 | 14 | import type { PrinterReport } from "../../devices/ipp.ts"; |
| @@ -37,7 +38,33 @@ function supplies(report: PrinterReport): Html | null { | ||
| 37 | 38 | </details>`; |
| 38 | 39 | } |
| 39 | 40 | |
| 40 | function card(d: Device): Html { | |
| 41 | /** Who may print here, as one fact: each granted app is a row with its revoke button, then a picker for one more. */ | |
| 42 | function access(ctx: Ctx, d: Device): Html { | |
| 43 | const granted = grantedSlugs(ctx.app.db, d.id); | |
| 44 | const rest = allAppSlugs(ctx.app.db).filter((s) => !granted.includes(s)); | |
| 45 | if (granted.length === 0 && rest.length === 0) return html``; | |
outdated
DESIGN.md 1, 3 and 7: hand-rolled 'small' div with a repeated muted label and 'none'. A fact is a dl row; each grant is a row with its revoke button. Also no terminal screen for this (both doors). |
||
| 46 | return html`<dl class="status-rows"><div class="status-row"><dt>Allowed apps</dt><dd> | |
| 47 | <ul class="list">${granted.map( | |
| 48 | (slug) => html`<li>${row({ | |
| 49 | title: html`<span class="mono">${slug}</span>`, | |
| 50 | trail: html`<form class="row-actions" method="post" action="/settings/devices/revoke"> | |
| 51 | <input type="hidden" name="id" value="${String(d.id)}" /> | |
| 52 | <input type="hidden" name="app" value="${slug}" /> | |
| 53 | ${btn("Revoke", { size: "sm", icon: "x", label: `Revoke ${slug} from ${d.name}` })} | |
| 54 | </form>`, | |
| 55 | })}</li>`, | |
| 56 | )}</ul> | |
| 57 | ${when(rest.length > 0, () => html`<form class="edit-row" method="post" action="/settings/devices/grant"> | |
| 58 | <input type="hidden" name="id" value="${String(d.id)}" /> | |
| 59 | <select class="input" name="app" aria-label="App to grant ${d.name}"> | |
| 60 | ${rest.map((slug) => html`<option value="${slug}">${slug}</option>`)} | |
| 61 | </select> | |
| 62 | ${btn("Grant", { size: "sm", icon: "plus", label: `Grant an app ${d.name}` })} | |
| 63 | </form>`)} | |
| 64 | </dd></div></dl>`; | |
| 65 | } | |
| 66 | ||
| 67 | function card(ctx: Ctx, d: Device): Html { | |
| 41 | 68 | return html`<li> |
| 42 | 69 | ${row({ |
| 43 | 70 | title: html`${d.name} <span class="muted small">printer</span>`, |
| @@ -48,6 +75,7 @@ function card(d: Device): Html { | ||
| 48 | 75 | </form>`, |
| 49 | 76 | })} |
| 50 | 77 | ${d.probe ? supplies(d.probe) : null} |
| 78 | ${access(ctx, d)} | |
| 51 | 79 | </li>`; |
| 52 | 80 | } |
| 53 | 81 | |
| @@ -67,7 +95,7 @@ export async function devicesPage(ctx: Ctx): Promise<Response> { | ||
| 67 | 95 | html`<section class="settings-block"> |
| 68 | 96 | <p class="muted">Printers on your network, as the forge sees them; only addresses like 192.168.1.161 are accepted.</p> |
| 69 | 97 | ${when(devices.length === 0, () => html`<p class="muted">No devices declared.</p>`)} |
| 70 | ${when(devices.length > 0, () => html`<ul class="list setting-list">${devices.map(card)}</ul>`)} | |
| 98 | ${when(devices.length > 0, () => html`<ul class="list setting-list">${devices.map((d) => card(ctx, d))}</ul>`)} | |
| 71 | 99 | <form class="edit-row mt" method="post" action="/settings/devices"> |
| 72 | 100 | <input type="hidden" name="kind" value="printer" /> |
| 73 | 101 | <input class="input" type="text" name="name" placeholder="Name" aria-label="Device name" maxlength="${String(NAME_MAX)}" required /> |
| @@ -113,3 +141,20 @@ export async function deviceRemove(ctx: Ctx): Promise<Response> { | ||
| 113 | 141 | gone.ok ? { message: "Removed." } : { tone: "danger", message: DEVICE_ERRORS[gone.error] }, |
| 114 | 142 | ); |
| 115 | 143 | } |
| 144 | ||
| 145 | // POST /settings/devices/grant and /settings/devices/revoke | |
| 146 | async function grantChange(ctx: Ctx, change: typeof grant, done: string): Promise<Response> { | |
| 147 | const denied = guard(ctx); | |
| 148 | if (denied) return denied; | |
| 149 | const form = await readForm(ctx, "admin"); | |
| 150 | if (form instanceof Response) return form; | |
| 151 | const result = change(ctx.app, ctx.auth.user, Number(field(form, "id")), field(form, "app")); | |
| 152 | return flashTo( | |
| 153 | ctx, | |
| 154 | "/settings/devices", | |
| 155 | result.ok ? { message: `${result.value} ${done}.` } : { tone: "danger", message: DEVICE_ERRORS[result.error] }, | |
| 156 | ); | |
| 157 | } | |
| 158 | ||
| 159 | export const deviceGrant = (ctx: Ctx): Promise<Response> => grantChange(ctx, grant, "may print here now"); | |
| 160 | export const deviceRevoke = (ctx: Ctx): Promise<Response> => grantChange(ctx, revoke, "may no longer print here"); |
test/checks-home-sweep.test.ts +7 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | * (#7673). |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 10 | import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test"; | |
| 11 | 11 | import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 12 | 12 | import { tmpdir } from "node:os"; |
| 13 | 13 | import { join } from "node:path"; |
| @@ -43,6 +43,12 @@ beforeAll(async () => { | ||
| 43 | 43 | }); |
| 44 | 44 | afterAll(() => rmSync(dataDir, { recursive: true, force: true })); |
| 45 | 45 | |
| 46 | // A sweep an earlier test started (the timer starts one) may still be in flight, and sweepSoon answers it | |
| 47 | // rather than a new one: let it end before each test. | |
| 48 | beforeEach(async () => { | |
| 49 | await sweepSoon(app); | |
| 50 | }); | |
| 51 | ||
| 46 | 52 | const home = (id: string): string => join(dataDir, "checks", `${id}.home`); |
| 47 | 53 | const populate = (id: string, readOnly = false): void => { |
| 48 | 54 | const deep = join(home(id), ".bun", "install", "cache"); |
test/print.test.ts added +228
| @@ -0,0 +1,228 @@ | ||
| 1 | /** An app granted a printer prints a PDF through the forge, and reaches nothing else. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { mintAppToken, revokeAppToken } from "../src/apps/token.ts"; | |
| 8 | import { grantDevice, isGranted } from "../src/core/device_grants.ts"; | |
| 9 | import { declareDevice } from "../src/core/devices.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { getUserByName } from "../src/core/users.ts"; | |
| 12 | import { PDF_MAX_BYTES, PRINTS_PER_MINUTE, checkPdf, pageCount } from "../src/devices/print.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { adminCookie } from "./helpers/admin.ts"; | |
| 15 | ||
| 16 | const enc = new TextEncoder(); | |
| 17 | const attr = (tag: number, name: string, value: string | number) => { | |
| 18 | const n = enc.encode(name); | |
| 19 | const v = typeof value === "number" ? [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255] : [...enc.encode(value)]; | |
| 20 | return [tag, n.length >> 8, n.length & 255, ...n, v.length >> 8, v.length & 255, ...v]; | |
| 21 | }; | |
| 22 | ||
| 23 | /** A one-page-per-leaf PDF the checker can read. */ | |
| 24 | const pdf = (pages: number): Uint8Array => | |
| 25 | enc.encode( | |
| 26 | `%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Count ${pages} /Kids [] >>\nendobj\n` + | |
| 27 | Array.from({ length: pages }, (_, i) => `${i + 3} 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n`).join("") + | |
| 28 | "trailer\n<< /Root 1 0 R >>\n%%EOF\n", | |
| 29 | ); | |
| 30 | ||
| 31 | let received: Uint8Array[] = []; | |
| 32 | const printer = Bun.serve({ | |
| 33 | port: 0, | |
| 34 | hostname: "127.0.0.1", | |
| 35 | async fetch(req) { | |
| 36 | received.push(new Uint8Array(await req.arrayBuffer())); | |
| 37 | return new Response( | |
| 38 | Uint8Array.from([ | |
| 39 | 1, 1, 0, 0, 0, 0, 0, 1, | |
| 40 | 0x01, ...attr(0x47, "attributes-charset", "utf-8"), | |
| 41 | 0x02, ...attr(0x21, "job-id", 42), ...attr(0x23, "job-state", 9), ...attr(0x44, "job-state-reasons", "job-completed-successfully"), | |
| 42 | 0x03, | |
| 43 | ]), | |
| 44 | { headers: { "content-type": "application/ipp" } }, | |
| 45 | ); | |
| 46 | }, | |
| 47 | }); | |
| 48 | ||
| 49 | let dataDir = ""; | |
| 50 | let app: App, servers: Servers, cookie = ""; | |
| 51 | let deviceId = 0, otherId = 0; | |
| 52 | let granted = "", ungranted = "", plainToken = ""; | |
| 53 | const prior = process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 54 | ||
| 55 | function addApp(id: string, slug: string): void { | |
| 56 | app.db.run("PRAGMA foreign_keys = OFF"); | |
| 57 | app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')", [id, `repo-${id}`, slug]); | |
| 58 | app.db.run("PRAGMA foreign_keys = ON"); | |
| 59 | } | |
| 60 | ||
| 61 | beforeAll(async () => { | |
| 62 | process.env.FORGE_DEVICES_ALLOW_LOOPBACK = "1"; | |
| 63 | dataDir = await mkdtemp(join(tmpdir(), "forge-print-")); | |
| 64 | app = createApp({ dataDir, port: 0 }); | |
| 65 | servers = await serve(app); | |
| 66 | cookie = await adminCookie(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" }); | |
| 67 | const owner = getUserByName(app.db, "alice")!; | |
| 68 | addApp("a1", "labels"); | |
| 69 | addApp("a2", "other"); | |
| 70 | const url = `ipp://127.0.0.1:${printer.port}/ipp/print`; | |
| 71 | const d = declareDevice(app.db, { name: "Hall", kind: "printer", url }); | |
| 72 | const e = declareDevice(app.db, { name: "Attic", kind: "printer", url }); | |
| 73 | if (!d.ok || !e.ok) throw new Error("declare"); | |
| 74 | deviceId = d.value.id; | |
| 75 | otherId = e.value.id; | |
| 76 | expect(grantDevice(app.db, deviceId, "labels", owner.id).ok).toBe(true); | |
| 77 | granted = mintAppToken(app.db, owner.id, "labels", "production").token; | |
| 78 | ungranted = mintAppToken(app.db, owner.id, "other", "production").token; | |
| 79 | // A person's own token that merely borrows an app's name is not an app. | |
| 80 | plainToken = createToken(app.db, owner.id, "app:labels/production", "ro").token; | |
| 81 | }); | |
| 82 | afterAll(async () => { | |
| 83 | if (prior === undefined) delete process.env.FORGE_DEVICES_ALLOW_LOOPBACK; | |
| 84 | else process.env.FORGE_DEVICES_ALLOW_LOOPBACK = prior; | |
| 85 | printer.stop(true); | |
| 86 | servers.stop(); | |
| 87 | app.close(); | |
| 88 | await rm(dataDir, { recursive: true, force: true }); | |
| 89 | }); | |
| 90 | ||
| 91 | const print = (path: string, token: string | undefined, body: string | Uint8Array) => | |
| 92 | fetch(`${servers.url}${path}`, { | |
| 93 | method: "POST", | |
| 94 | headers: { connection: "close", "content-type": "application/pdf", ...(token ? { authorization: `Bearer ${token}` } : {}) }, | |
| 95 | body, | |
| 96 | }); | |
| 97 | ||
| 98 | test("a granted app prints and gets the job state", async () => { | |
| 99 | received = []; | |
| 100 | const res = await print(`/api/v1/devices/${deviceId}/print`, granted, pdf(2)); | |
| 101 | expect(res.status).toBe(200); | |
| 102 | expect(await res.json()).toEqual({ job: { id: 42, state: "completed", reasons: ["job-completed-successfully"] } }); | |
| 103 | expect(received.length).toBe(1); | |
| 104 | const sent = received[0]!; | |
| 105 | expect([...sent.slice(0, 4)]).toEqual([1, 1, 0, 2]); // Print-Job | |
| 106 | expect(Buffer.from(sent).toString("latin1")).toContain("application/pdf"); | |
| 107 | expect(Buffer.from(sent).toString("latin1")).toContain("%PDF-1.4"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("an app without the grant is 403, and nothing is sent", async () => { | |
| 111 | received = []; | |
| 112 | expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(403); | |
| 113 | // Granted one device, not the other. | |
| 114 | expect((await print(`/api/v1/devices/${otherId}/print`, granted, pdf(1))).status).toBe(403); | |
| 115 | // A person's token named like an app is not an app. | |
| 116 | expect((await print(`/api/v1/devices/${deviceId}/print`, plainToken, pdf(1))).status).toBe(403); | |
| 117 | expect((await print(`/api/v1/devices/${deviceId}/print`, undefined, pdf(1))).status).toBe(401); | |
| 118 | expect(received.length).toBe(0); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a stopped app's token no longer prints", async () => { | |
| 122 | const owner = getUserByName(app.db, "alice")!; | |
| 123 | const t = mintAppToken(app.db, owner.id, "labels", "preview"); | |
| 124 | expect((await print(`/api/v1/devices/${deviceId}/print`, t.token, pdf(1))).status).toBe(200); | |
| 125 | revokeAppToken(app.db, t.id); | |
| 126 | expect((await print(`/api/v1/devices/${deviceId}/print`, t.token, pdf(1))).status).toBe(403); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a non-PDF, an oversize or a long document is 422 and is not sent", async () => { | |
| 130 | received = []; | |
| 131 | const path = `/api/v1/devices/${deviceId}/print`; | |
| 132 | expect((await print(path, granted, "just text")).status).toBe(422); | |
| 133 | expect((await print(path, granted, enc.encode("%PDF-1.4 truncated, no end"))).status).toBe(422); | |
| 134 | expect((await print(path, granted, pdf(21))).status).toBe(422); | |
| 135 | expect((await print(path, granted, pdf(20))).status).toBe(200); | |
| 136 | received = []; | |
| 137 | const big = new Uint8Array(10 * 1024 * 1024 + 1); | |
| 138 | big.set(enc.encode("%PDF-1.4\n")); | |
| 139 | const res = await print(path, granted, big); | |
| 140 | expect(res.status).toBe(422); | |
| 141 | expect(((await res.json()) as { error: string }).error).toBe("too_large"); | |
| 142 | expect(received.length).toBe(0); // the oversize body closes its connection, so it goes last | |
| 143 | }); | |
| 144 | ||
| 145 | test("a request naming any other address is refused, each with its own status", async () => { | |
| 146 | received = []; | |
| 147 | const lan = encodeURIComponent("ipp://192.168.1.50/ipp/print"); | |
| 148 | // Not a device number, or not a declared device: the same 403 as an ungranted one, whatever the body. | |
| 149 | expect((await print(`/api/v1/devices/${lan}/print`, granted, pdf(1))).status).toBe(403); | |
| 150 | expect((await print("/api/v1/devices/192.168.1.50/print", granted, pdf(1))).status).toBe(403); | |
| 151 | expect((await print("/api/v1/devices/999/print", granted, pdf(1))).status).toBe(403); | |
| 152 | expect(received.length).toBe(0); | |
| 153 | // A named address in the query is ignored: the job still goes only to the declared one. | |
| 154 | expect((await print(`/api/v1/devices/${deviceId}/print?url=${lan}`, granted, pdf(1))).status).toBe(200); | |
| 155 | expect(received.length).toBe(1); | |
| 156 | }); | |
| 157 | ||
| 158 | test("an ungranted caller is refused before its body is read", async () => { | |
| 159 | const big = new Uint8Array(PDF_MAX_BYTES + 1); | |
| 160 | const res = await print(`/api/v1/devices/${deviceId}/print`, "not-a-token", big); | |
| 161 | expect(res.status).toBe(403); // not 422 too_large: the size was never looked at | |
| 162 | }); | |
| 163 | ||
| 164 | test("an app is limited to a few prints a minute", async () => { | |
| 165 | const owner = getUserByName(app.db, "alice")!; | |
| 166 | addApp("a3", "chatty"); | |
| 167 | grantDevice(app.db, deviceId, "chatty", owner.id); | |
| 168 | const t = mintAppToken(app.db, owner.id, "chatty", "production").token; | |
| 169 | const statuses: number[] = []; | |
| 170 | for (let i = 0; i < PRINTS_PER_MINUTE + 1; i++) statuses.push((await print(`/api/v1/devices/${deviceId}/print`, t, pdf(1))).status); | |
| 171 | expect(statuses.slice(0, PRINTS_PER_MINUTE).every((s) => s === 200)).toBe(true); | |
| 172 | expect(statuses[PRINTS_PER_MINUTE]).toBe(429); | |
| 173 | // Another app's budget is its own. | |
| 174 | expect((await print(`/api/v1/devices/${deviceId}/print`, granted, pdf(1))).status).toBe(200); | |
| 175 | }); | |
| 176 | ||
| 177 | test("prints, grants and revokes append their events", () => { | |
| 178 | const kinds = app.db.query<{ kind: string; payload: string }, []>("SELECT kind, payload FROM event WHERE kind LIKE 'device.%'").all(); | |
| 179 | const printed = kinds.find((k) => k.kind === "device.printed"); | |
| 180 | expect(printed).toBeDefined(); | |
| 181 | expect(JSON.parse(printed!.payload)).toMatchObject({ device: "Hall", app: "labels", pages: 2 }); | |
| 182 | }); | |
| 183 | ||
| 184 | test("the owner grants and revokes on the device page", async () => { | |
| 185 | const post = (path: string, body: Record<string, string>) => | |
| 186 | fetch(`${servers.url}${path}`, { | |
| 187 | method: "POST", | |
| 188 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie }, | |
| 189 | body: new URLSearchParams(body), | |
| 190 | redirect: "manual", | |
| 191 | }); | |
| 192 | expect((await post("/settings/devices/grant", { id: String(deviceId), app: "other" })).status).toBe(303); | |
| 193 | expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(200); | |
| 194 | const page = await (await fetch(`${servers.url}/settings/devices`, { headers: { cookie } })).text(); | |
| 195 | expect(page).toContain("Allowed apps"); | |
| 196 | const events = () => app.db.query<{ kind: string }, []>("SELECT kind FROM event").all().map((e) => e.kind); | |
| 197 | expect(events()).toContain("device.granted"); | |
| 198 | expect((await post("/settings/devices/revoke", { id: String(deviceId), app: "other" })).status).toBe(303); | |
| 199 | expect(events()).toContain("device.revoked"); | |
| 200 | expect((await print(`/api/v1/devices/${deviceId}/print`, ungranted, pdf(1))).status).toBe(403); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a non-admin is refused on grant and revoke, and changes nothing", async () => { | |
| 204 | const { createUser } = await import("../src/core/users.ts"); | |
| 205 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 206 | if (!made.ok) throw new Error(made.error); | |
| 207 | const { createSession } = await import("../src/core/sessions.ts"); | |
| 208 | const bob = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 209 | const res = await fetch(`${servers.url}/settings/devices/grant`, { | |
| 210 | method: "POST", | |
| 211 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url, cookie: bob }, | |
| 212 | body: new URLSearchParams({ id: String(otherId), app: "other" }), | |
| 213 | redirect: "manual", | |
| 214 | }); | |
| 215 | expect(res.status).not.toBe(303); | |
| 216 | expect(isGranted(app.db, otherId, "a2")).toBe(false); | |
| 217 | }); | |
| 218 | ||
| 219 | test("an object stream cannot hide the page tree behind a plain decoy", () => { | |
| 220 | const decoy = "%PDF-1.5\n<< /Type /Pages /Count 1 >>\n5 0 obj\n<< /Type /ObjStm /N 1 >>\nstream\n\nendstream\n%%EOF\n"; | |
| 221 | expect(checkPdf(enc.encode(decoy))).toEqual({ ok: false, error: "pages_unknown" }); | |
| 222 | expect(checkPdf(enc.encode(decoy.replace("/ObjStm", "/Obj#53tm")))).toEqual({ ok: false, error: "pages_unknown" }); | |
| 223 | }); | |
| 224 | ||
| 225 | test("the page count is read from the plain page tree", () => { | |
| 226 | expect(pageCount(pdf(3))).toBe(3); | |
| 227 | expect(checkPdf(enc.encode("%PDF-1.5\n<< /Type /ObjStm >>\n%%EOF"))).toEqual({ ok: false, error: "pages_unknown" }); | |
| 228 | }); |
test/tui-devices.test.ts +27 −1
| @@ -7,7 +7,8 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | import { listDevices } from "../src/core/devices.ts"; | |
| 10 | import { declareDevice, listDevices } from "../src/core/devices.ts"; | |
| 11 | import { grantedSlugs } from "../src/core/device_grants.ts"; | |
| 11 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 13 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 13 | 14 | import { devicesScreen } from "../src/tui/screens/devices.ts"; |
| @@ -97,4 +98,29 @@ describe("the devices screen", () => { | ||
| 97 | 98 | expect(listDevices(app.db)).toEqual([]); |
| 98 | 99 | expect(kinds.includes("device.removed")).toBe(true); |
| 99 | 100 | })); |
| 101 | ||
| 102 | test("a allows the device under the cursor to an app and r revokes it, with the page's events", () => | |
| 103 | (async () => { | |
| 104 | const d = declareDevice(app.db, { name: "Den", kind: "printer", url: "ipp://192.168.1.162/ipp/print" }); | |
| 105 | if (!d.ok) throw new Error(d.error); | |
| 106 | app.db.run("PRAGMA foreign_keys = OFF"); | |
| 107 | app.db.run("INSERT INTO app (id, repo_id, slug, access) VALUES ('t1', 'repo-t1', 'labels', 'private')"); | |
| 108 | app.db.run("PRAGMA foreign_keys = ON"); | |
| 109 | await withScreen(admin, async ({ text, feed }) => { | |
| 110 | const mine = () => grantedSlugs(app.db, d.value.id); | |
| 111 | await feed("a"); | |
| 112 | await feed("nope\r"); | |
| 113 | expect(text()).toContain("No such app"); | |
| 114 | expect(mine()).toEqual([]); | |
| 115 | for (let n = 0; n < 4; n++) await feed("\x7f"); | |
| 116 | await feed("labels\r"); | |
| 117 | expect(mine()).toEqual(["labels"]); | |
| 118 | expect(kinds.includes("device.granted")).toBe(true); | |
| 119 | expect(text()).toContain("apps: labels"); | |
| 120 | await feed("r"); | |
| 121 | await feed("labels\r"); | |
| 122 | expect(mine()).toEqual([]); | |
| 123 | expect(kinds.includes("device.revoked")).toBe(true); | |
| 124 | }); | |
| 125 | })()); | |
| 100 | 126 | }); |
A PDF with a plain fake '/Type /Pages /Count 1' and the real tree in an /ObjStm passes the 20-page cap. Refuse /ObjStm outright or parse the tree.