1use std::io::{Cursor, Read};
4use std::sync::Arc;
5
6use axum::body::Bytes;
7use axum::extract::{Path, State};
8use axum::http::{header, StatusCode};
9use axum::response::IntoResponse;
10use axum::routing::{get, post};
11use axum::Router;
12use ipp::model::Operation;
13use ipp::parser::IppParser;
14use ipp::model::StatusCode as IppStatus;
15use ipp::prelude::*;
16use ipp::reader::IppReader;
17use num_traits::FromPrimitive;
18use crate::attributes::{
19 self, build_get_jobs_response, build_job_attrs_response, get_printer_attributes,
20 print_job_accepted, validate_job,
21};
22use crate::device::DeviceBackend;
23use crate::job::{JobId, JobRegistry, JobState};
24use crate::printer::{PrinterRecord, PrinterRegistry};
25use crate::raster::JobFailure;
26use crate::state::PersistedState;
27
28#[derive(Clone)]
31#[allow(missing_docs)]
32pub struct JobContext {
33 pub id: JobId,
34 pub printer_name: String,
35 pub cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
36}
37
38pub type PrintJobFn = Arc<
43 dyn Fn(JobContext, Vec<u8>, u32) -> Result<(), JobFailure>
44 + Send
45 + Sync,
46>;
47
48#[allow(missing_docs)]
50pub struct ServerOptions {
51 pub host: String,
52 pub port: u16,
53 pub printers: PrinterRegistry,
54 pub device_backend: Arc<dyn DeviceBackend>,
55 pub print_job: PrintJobFn,
56 pub state_path: std::path::PathBuf,
57}
58
59#[derive(Clone)]
62#[allow(missing_docs)]
63pub struct AppState {
64 pub host: String,
65 pub port: u16,
66 pub printers: PrinterRegistry,
67 pub print_job: PrintJobFn,
68 pub state_path: std::path::PathBuf,
69 pub jobs: JobRegistry,
70 pub device_backend: Arc<dyn DeviceBackend>,
71}
72
73pub struct Server;
75
76impl Server {
77 pub fn router(opts: ServerOptions) -> Router {
80 let state = AppState {
81 host: opts.host.clone(),
82 port: opts.port,
83 printers: opts.printers.clone(),
84 print_job: opts.print_job,
85 state_path: opts.state_path,
86 jobs: JobRegistry::new(),
87 device_backend: opts.device_backend,
88 };
89
90 Router::new()
91 .route("/", get(index_handler))
92 .route("/ipp/print/{name}", post(ipp_handler))
93 .route("/ipp/print/{name}/", post(ipp_handler))
94 .with_state(state)
95 }
96
97 pub async fn run(opts: ServerOptions) -> std::io::Result<()> {
101 let addr = format!("{}:{}", opts.host, opts.port);
102 let listener = tokio::net::TcpListener::bind(&addr).await?;
103 log::info!("ipp-printer-app listening on http://{addr}");
104
105 let _status = crate::status::spawn(opts.device_backend.clone(), opts.printers.clone());
107
108 #[cfg(feature = "mdns")]
110 let _advertiser = match crate::mdns::Advertiser::register_all(&opts.printers, opts.port) {
111 Ok(adv) => Some(adv),
112 Err(e) => {
113 log::warn!("mdns: failed to register printers: {e}");
114 None
115 }
116 };
117
118 axum::serve(listener, Self::router(opts)).await
119 }
120
121 pub fn bootstrap_printers(
123 registry: &PrinterRegistry,
124 backend: &dyn DeviceBackend,
125 state_path: &std::path::Path,
126 make_config: impl Fn(&str, &str, &str, &str) -> Option<crate::printer::PrinterConfig>,
127 ) {
128 let mut records: Vec<PrinterRecord> = PersistedState::load(state_path)
129 .printers
130 .into_iter()
131 .map(PrinterRecord::new)
132 .collect();
133
134 backend.list(&mut |info, uri, device_id| {
135 let driver = match backend.driver_for_device(device_id, uri) {
136 Some(d) => d,
137 None => return true,
138 };
139 let name = printer_name_from_uri(uri, info);
140 if records.iter().any(|r| r.config.device_uri == uri) {
141 return true;
142 }
143 let Some(cfg) = make_config(&name, &driver, uri, device_id) else {
144 return true;
145 };
146 log::info!("auto-add printer {name} -> {uri}");
147 records.push(PrinterRecord::new(cfg));
148 true
149 });
150
151 *registry.write() = records;
152 Self::persist(registry, state_path);
153 }
154
155 pub fn persist(registry: &PrinterRegistry, state_path: &std::path::Path) {
159 let configs: Vec<_> = registry
160 .read()
161 .iter()
162 .map(|r| r.config.clone())
163 .collect();
164 let _ = PersistedState { printers: configs }.save(state_path);
165 }
166}
167
168fn printer_name_from_uri(uri: &str, info: &str) -> String {
172 let source = if info.is_empty() { uri } else { info };
173 let slug: String = source
174 .chars()
175 .map(|c| {
176 if c.is_ascii_alphanumeric() {
177 c.to_ascii_lowercase()
178 } else {
179 '-'
180 }
181 })
182 .collect();
183 let trimmed = slug.trim_matches('-');
184 let collapsed: String = trimmed
185 .split('-')
186 .filter(|s| !s.is_empty())
187 .collect::<Vec<_>>()
188 .join("-");
189 if collapsed.is_empty() {
190 "printer".to_string()
191 } else {
192 collapsed
193 }
194}
195
196async fn index_handler(State(state): State<AppState>) -> impl IntoResponse {
197 let printers = state.printers.read();
198 let mut html = String::from(
199 "<!DOCTYPE html><html><head><title>ipp-printer-app</title></head><body>\
200 <h1>ipp-printer-app</h1><ul>",
201 );
202 for p in printers.iter() {
203 let uri = p.config.printer_uri(&state.host, state.port);
204 html.push_str(&format!(
205 "<li><b>{}</b> — <code>{uri}</code> — device <code>{}</code></li>",
206 p.config.name, p.config.device_uri
207 ));
208 }
209 html.push_str(&format!(
210 "</ul><p>Register with CUPS: <code>lpadmin -p NAME -E -v \
211 ipp://{}:{}/ipp/print/NAME -m everywhere</code></p></body></html>",
212 if state.host.is_empty() || state.host == "0.0.0.0" || state.host == "::" {
213 "localhost"
214 } else {
215 &state.host
216 },
217 state.port,
218 ));
219 (StatusCode::OK, [(header::CONTENT_TYPE, "text/html; charset=utf-8")], html)
220}
221
222async fn ipp_handler(
223 State(state): State<AppState>,
224 Path(name): Path<String>,
225 body: Bytes,
226) -> impl IntoResponse {
227 match handle_ipp(&state, &name, &body) {
228 Ok(bytes) => (
229 StatusCode::OK,
230 [(header::CONTENT_TYPE, "application/ipp")],
231 bytes,
232 ),
233 Err((status, msg)) => (
234 status,
235 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
236 msg.into_bytes(),
237 ),
238 }
239}
240
241fn handle_ipp(state: &AppState, name: &str, body: &[u8]) -> Result<Vec<u8>, (StatusCode, String)> {
242 let mut req = IppParser::new(IppReader::new(Cursor::new(body.to_vec())))
243 .parse()
244 .map_err(|e| (StatusCode::BAD_REQUEST, format!("IPP parse error: {e}")))?;
245
246 let version = req.header().version;
247 let request_id = req.header().request_id;
248 let op = Operation::from_u16(req.header().operation_or_status)
249 .ok_or((StatusCode::BAD_REQUEST, "unknown IPP operation".into()))?;
250
251 let record = {
252 let guard = state.printers.read();
253 guard
254 .iter()
255 .find(|p| p.config.name == name)
256 .cloned()
257 .ok_or((StatusCode::NOT_FOUND, format!("printer not found: {name}")))?
258 };
259
260 let resp = match op {
261 Operation::GetPrinterAttributes => get_printer_attributes(
262 version,
263 request_id,
264 &record,
265 &state.host,
266 state.port,
267 )
268 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?,
269 Operation::ValidateJob => validate_job(version, request_id, &record, &state.host, state.port)
270 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?,
271 Operation::PrintJob => {
272 let copies = extract_copies(&req);
273 let mut payload = Vec::new();
274 req.payload_mut()
275 .read_to_end(&mut payload)
276 .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
277
278 let job = state.jobs.create(name.to_string());
279 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
280 let accepted = print_job_accepted(version, request_id, &job, &printer_uri_str)
281 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
282
283 let state_clone = state.clone();
284 let name_owned = name.to_string();
285 let job_for_worker = job.clone();
286 std::thread::spawn(move || {
287 {
288 let mut guard = state_clone.printers.write();
289 if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
290 attributes::set_printer_processing(p);
291 }
292 }
293 state_clone
294 .jobs
295 .set_state(job_for_worker.id, JobState::Processing);
296 let ctx = JobContext {
297 id: job_for_worker.id,
298 printer_name: name_owned.clone(),
299 cancel_flag: job_for_worker.cancel_flag.clone(),
300 };
301 let result = (state_clone.print_job)(ctx, payload, copies);
302 {
303 let mut guard = state_clone.printers.write();
304 if let Some(p) = guard.iter_mut().find(|p| p.config.name == name_owned) {
305 attributes::set_printer_idle(p);
306 match &result {
307 Ok(()) => p.reasons = crate::flags::PrinterReason::empty(),
308 Err(f) => p.reasons = f.printer_reasons,
309 }
310 }
311 }
312 match result {
313 Ok(()) => {
314 if !job_for_worker.cancel_flag.load(std::sync::atomic::Ordering::Acquire) {
317 state_clone
318 .jobs
319 .set_state(job_for_worker.id, JobState::Completed);
320 }
321 }
322 Err(f) => {
323 log::error!(
324 "print job {} failed: {} (reasons={:?})",
325 job_for_worker.id,
326 f.message,
327 f.printer_reasons,
328 );
329 state_clone
330 .jobs
331 .set_failure(job_for_worker.id, f.printer_reasons, f.message);
332 }
333 }
334 Server::persist(&state_clone.printers, &state_clone.state_path);
335 });
336
337 accepted
338 }
339 Operation::GetJobs => {
340 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
341 let jobs = state.jobs.jobs_for_printer(name);
342 build_get_jobs_response(version, request_id, &jobs, &printer_uri_str)
343 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
344 }
345 Operation::GetJobAttributes => {
346 let printer_uri_str = record.config.printer_uri(&state.host, state.port);
347 let job_id = extract_job_id(&req).ok_or((
348 StatusCode::BAD_REQUEST,
349 "Get-Job-Attributes missing job-id".to_string(),
350 ))?;
351 let job = state.jobs.get(job_id).ok_or((
352 StatusCode::NOT_FOUND,
353 format!("job not found: {job_id}"),
354 ))?;
355 build_job_attrs_response(version, request_id, &job, &printer_uri_str)
356 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
357 }
358 Operation::CancelJob => {
359 let job_id = extract_job_id(&req).ok_or((
360 StatusCode::BAD_REQUEST,
361 "Cancel-Job missing job-id".to_string(),
362 ))?;
363 let status = match state.jobs.cancel(job_id) {
364 None => IppStatus::ClientErrorNotFound,
365 Some(JobState::Canceled) => IppStatus::SuccessfulOk,
366 Some(_) => IppStatus::ClientErrorNotPossible,
367 };
368 IppRequestResponse::new_response(version, status, request_id)
369 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
370 }
371 _ => {
372 return Err((
373 StatusCode::BAD_REQUEST,
374 format!("unsupported IPP operation: {op:?}"),
375 ));
376 }
377 };
378
379 Ok(resp.to_bytes().to_vec())
380}
381
382fn extract_job_id(req: &IppRequestResponse) -> Option<JobId> {
383 for group in req.attributes().groups() {
384 for attr in group.attributes().values() {
385 if attr.name().as_str() == "job-id" {
386 if let IppValue::Integer(n) = attr.value() {
387 return Some((*n) as JobId);
388 }
389 }
390 if attr.name().as_str() == "job-uri" {
391 if let IppValue::Uri(s) = attr.value() {
392 return s.as_str().rsplit('/').next().and_then(|s| s.parse().ok());
393 }
394 }
395 }
396 }
397 None
398}
399
400fn extract_copies(req: &IppRequestResponse) -> u32 {
401 for group in req.attributes().groups() {
402 for attr in group.attributes().values() {
403 if attr.name().as_str() == "copies" {
404 if let IppValue::Integer(n) = attr.value() {
405 return (*n).max(1) as u32;
406 }
407 }
408 }
409 }
410 0
411}