Skip to main content

ipp_printer_app/
server.rs

1//! Axum HTTP server: IPP over POST `/ipp/print/:name`.
2
3use 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/// Context passed to a print-job worker so it can observe cancellation and
29/// report progress without re-querying the registry.
30#[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
38/// Callback to process a CUPS raster document on a device.
39///
40/// Returning `Err(JobFailure)` lets the framework propagate
41/// `job-state-reasons` / `job-state-message` to IPP clients.
42pub type PrintJobFn = Arc<
43    dyn Fn(JobContext, Vec<u8>, u32) -> Result<(), JobFailure>
44        + Send
45        + Sync,
46>;
47
48/// Server configuration. Construct in your `main`, hand to [`Server::run`].
49#[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/// Axum-shared state. Constructed internally by [`Server::router`]; exposed
60/// only so external middleware can read the printer registry.
61#[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
73/// Entry point — `Server::run(opts).await` starts the listener.
74pub struct Server;
75
76impl Server {
77    /// Build the axum router with the configured state attached. Returned
78    /// router can be served via [`Server::run`] or by hand.
79    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    /// Bind to `host:port`, spawn the background status poller (and the mDNS
98    /// advertiser if the `mdns` feature is enabled), and run the axum
99    /// listener until it errors.
100    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        // Background status poller — keeps printer-state-reasons fresh.
106        let _status = crate::status::spawn(opts.device_backend.clone(), opts.printers.clone());
107
108        // mDNS advertising for IPP-Everywhere auto-discovery.
109        #[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    /// Load printers from disk, discover devices, merge into registry.
122    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    /// Snapshot the registry to `state_path` as JSON. Called automatically
156    /// at the end of every print job; expose for callers that want to
157    /// persist after manual registry edits.
158    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
168/// Generic slug used as the proposed printer name during bootstrap. The
169/// `make_config` callback receives this as its first arg and is free to
170/// override by returning a [`PrinterConfig`] with a different `name`.
171fn 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                        // Don't clobber a Cancel that landed while the worker
315                        // was running — the registry already saw it.
316                        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}