use axum::{ extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, path::{Path, PathBuf}, process::Command, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, }, time::{Duration, Instant}, }; use tempfile::{NamedTempFile, TempPath}; use tokio::fs; use tower_http::cors::CorsLayer; use tracing::{error, info, warn}; async fn get_version() -> impl IntoResponse { Json(serde_json::json!({ "version": "1.0.1", "timestamp": chrono::Utc::now().timestamp() })) } // ─────────────────────────────── Модели ─────────────────────────────── #[derive(Deserialize, Clone, Debug)] struct PrintSettings { filename: String, pages: String, copies: u32, #[serde(rename = "colorMode")] color_mode: String, format: String, #[serde(default)] sides: Option, #[serde(default)] dpi: Option, } #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] enum FileStatus { Queued, Printing, Awaiting, Done, Error, Cancelled, } #[derive(Clone, Debug, Serialize, PartialEq)] #[serde(rename_all = "snake_case")] enum JobPhase { WaitingStart, Printing, AwaitingPickup, Finished, Cancelled, } #[derive(Clone, Debug, Serialize)] struct JobFileView { name: String, status: FileStatus, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } #[derive(Clone, Debug, Serialize)] struct JobView { job_id: String, phase: JobPhase, files: Vec, } struct JobFile { name: String, path: TempPath, // живёт, пока жив job → автоочистка temp settings: Option, status: FileStatus, error: Option, } struct Job { id: String, files: Vec, current: Option, phase: JobPhase, cups_id: Option, } impl Job { fn view(&self) -> JobView { JobView { job_id: self.id.clone(), phase: self.phase.clone(), files: self .files .iter() .map(|f| JobFileView { name: f.name.clone(), status: f.status.clone(), error: f.error.clone(), }) .collect(), } } } #[derive(Clone)] struct SpawnInfo { job_id: String, idx: usize, path: PathBuf, settings: Option, } type JobStore = Arc>>; static JOB_COUNTER: AtomicU64 = AtomicU64::new(1); // ─────────────────────────────── main ─────────────────────────────── #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_target(true) .with_thread_ids(true) .with_file(true) .with_line_number(true) .init(); let store: JobStore = Arc::new(Mutex::new(HashMap::new())); let cors = CorsLayer::permissive(); let app = Router::new() .route("/api/version", get(get_version)) .route("/print", post(create_print)) .route("/print/{job_id}", get(job_status)) .route("/print/{job_id}/advance", post(job_advance)) .route("/print/{job_id}/cancel", post(job_cancel)) .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50 MB .layer(cors) .with_state(store); info!("🖨️ Print API запущен на http://0.0.0.0:3000"); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") .await .expect("Не удалось забиндить порт 3000"); axum::serve(listener, app).await.expect("Ошибка сервера"); } // ─────────────────────────── Обработчики ─────────────────────────── async fn create_print( State(store): State, mut multipart: Multipart, ) -> Result, (StatusCode, String)> { info!("📥 Получен запрос на печать (создание задания)"); let mut settings_map: HashMap = HashMap::new(); let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new(); while let Some(field) = multipart.next_field().await.unwrap_or(None) { let field_name = field.name().unwrap_or("").to_string(); if field_name == "settings" { match field.text().await { Ok(text) => match serde_json::from_str::(&text) { Ok(s) => { info!(filename = %s.filename, "✅ Настройки распарсены"); settings_map.insert(s.filename.clone(), s); } Err(e) => error!(raw = %text, error = %e, "❌ Не удалось распарсить settings"), }, Err(e) => error!(error = %e, "❌ Ошибка чтения поля settings"), } } else if field_name == "files" { let file_name = field.file_name().unwrap_or("unknown").to_string(); match field.bytes().await { Ok(data) => { info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл"); files_data.push((file_name, data)); } Err(e) => error!(file_name = %file_name, error = %e, "❌ Ошибка чтения файла"), } } else { warn!(field_name = %field_name, "⚠️ Неизвестное поле в multipart, пропущено"); } } if files_data.is_empty() { return Err(( StatusCode::BAD_REQUEST, "В запросе отсутствуют файлы для печати".into(), )); } // Спулим каждый файл во временный (TempPath живёт вместе с job) let mut job_files: Vec = Vec::new(); for (file_name, data) in files_data { let ext = Path::new(&file_name) .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) }; let temp = match temp { Ok(f) => f, Err(e) => { error!(file_name = %file_name, error = %e, "❌ Не удалось создать temp файл"); continue; } }; let path = temp.into_temp_path(); if let Err(e) = fs::write(&*path, &data).await { error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл"); continue; } let settings = settings_map.get(&file_name).cloned(); if settings.is_none() { warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию"); } job_files.push(JobFile { name: file_name, path, settings, status: FileStatus::Queued, error: None, }); } if job_files.is_empty() { return Err(( StatusCode::BAD_REQUEST, "Не удалось сохранить ни один файл".into(), )); } let job_id = format!("job-{}", JOB_COUNTER.fetch_add(1, Ordering::SeqCst)); let job = Job { id: job_id.clone(), files: job_files, current: None, phase: JobPhase::WaitingStart, cups_id: None, }; let view = job.view(); store.lock().unwrap().insert(job_id.clone(), job); info!(job_id = %job_id, files = view.files.len(), "🆕 Задание создано, ждём старт от клиента"); Ok(Json(view)) } async fn job_status( State(store): State, AxumPath(job_id): AxumPath, ) -> Result, (StatusCode, String)> { let m = store.lock().unwrap(); m.get(&job_id) .map(|j| Json(j.view())) .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id))) } /// Подтверждение клиента: старт первого файла ИЛИ «документ забран, печатай следующий» async fn job_advance( State(store): State, AxumPath(job_id): AxumPath, ) -> Result, (StatusCode, String)> { let (view, spawn_info) = { let mut m = store.lock().unwrap(); let job = m .get_mut(&job_id) .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; let si = match job.phase { JobPhase::WaitingStart => { info!(job_id = %job_id, "▶️ Клиент дал старт: печать первого файла"); Some(start_printing_locked(job, 0)) } JobPhase::AwaitingPickup => match job.current.take() { Some(c) => { job.files[c].status = FileStatus::Done; info!(job_id = %job_id, file = %job.files[c].name, "✔️ Пользователь забрал документ"); begin_next_locked(job, c) } None => { job.phase = JobPhase::Finished; None } }, // printing / finished / cancelled — идемпотентно игнорируем _ => None, }; (job.view(), si) }; if let Some(si) = spawn_info { spawn_print_task(store.clone(), si); } Ok(Json(view)) } async fn job_cancel( State(store): State, AxumPath(job_id): AxumPath, ) -> Result, (StatusCode, String)> { let cups_ids: Vec = { let mut m = store.lock().unwrap(); let job = m .get_mut(&job_id) .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Задание {} не найдено", job_id)))?; if job.phase == JobPhase::Finished || job.phase == JobPhase::Cancelled { return Ok(Json(job.view())); } // собираем ВСЕ известные CUPS id для этого job (обычно один, но на всякий случай) let ids = job.cups_id.take().into_iter().collect::>(); for f in job.files.iter_mut() { if matches!( f.status, FileStatus::Queued | FileStatus::Printing | FileStatus::Awaiting ) { f.status = FileStatus::Cancelled; } } job.phase = JobPhase::Cancelled; job.current = None; warn!(job_id = %job_id, "🛑 Задание отменено клиентом"); ids }; // отменяем каждый известный CUPS-job for id in cups_ids { let cancel_bin = cups_bin("cancel"); let _ = tokio::task::spawn_blocking(move || { Command::new(cancel_bin).arg(id).output() }) .await; } // также попробуем отменить по всем заданиям с нашего temp-префикса (belt and suspenders) // — но обычно CUPS-id'ов достаточно let m = store.lock().unwrap(); Ok(Json(m.get(&job_id).unwrap().view())) } // ─────────────────────── Печать: state machine ─────────────────────── fn start_printing_locked(job: &mut Job, idx: usize) -> SpawnInfo { job.files[idx].status = FileStatus::Printing; job.current = Some(idx); job.phase = JobPhase::Printing; job.cups_id = None; SpawnInfo { job_id: job.id.clone(), idx, path: job.files[idx].path.to_path_buf(), settings: job.files[idx].settings.clone(), } } fn begin_next_locked(job: &mut Job, from: usize) -> Option { match (from + 1..job.files.len()).find(|&i| job.files[i].status == FileStatus::Queued) { Some(i) => Some(start_printing_locked(job, i)), None => { job.phase = JobPhase::Finished; job.current = None; job.cups_id = None; info!(job_id = %job.id, "🎉 Задание полностью завершено"); None } } } fn spawn_print_task(store: JobStore, info: SpawnInfo) { tokio::spawn(async move { let path = info.path.clone(); let settings = info.settings.clone(); // 1) Спулим в CUPS let spool = tokio::task::spawn_blocking(move || run_lp(&path, settings.as_ref())).await; let cups_id = match spool { Ok(Ok(id)) => id, Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, Err(e) => { return mark_error_and_continue(store, info, format!("spawn: {}", e)).await } }; // 2) Ждём физического завершения печати if !cups_id.is_empty() { { let mut m = store.lock().unwrap(); match m.get_mut(&info.job_id) { Some(job) if job.phase != JobPhase::Cancelled => { job.cups_id = Some(cups_id.clone()) } _ => return, } } let cid = cups_id.clone(); let wait = tokio::task::spawn_blocking(move || wait_for_cups(&cid)).await; match wait { Ok(Ok(())) => {} Ok(Err(e)) => return mark_error_and_continue(store, info, e).await, Err(e) => { return mark_error_and_continue(store, info, format!("spawn: {}", e)).await } } } // 3) Допечатано → ждём подтверждения пользователя let mut m = store.lock().unwrap(); let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return, }; if job.phase == JobPhase::Cancelled || job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; } job.files[info.idx].status = FileStatus::Awaiting; job.phase = JobPhase::AwaitingPickup; job.cups_id = None; info!(job_id = %info.job_id, file = %job.files[info.idx].name, "✅ Файл допечатан, ждём подтверждения клиента"); }); } async fn mark_error_and_continue(store: JobStore, info: SpawnInfo, msg: String) { error!(job_id = %info.job_id, idx = info.idx, error = %msg, "❌ Ошибка печати файла"); let next = { let mut m = store.lock().unwrap(); let job = match m.get_mut(&info.job_id) { Some(j) => j, None => return, }; if job.phase == JobPhase::Cancelled { return; } if let Some(f) = job.files.get_mut(info.idx) { if f.status != FileStatus::Printing { return; } f.status = FileStatus::Error; f.error = Some(msg); } begin_next_locked(job, info.idx) }; if let Some(si) = next { spawn_print_task(store, si); } } // ─────────────────────────── lp / lpstat ─────────────────────────── /// Ищем бинарник CUPS в известных локациях (NixOS/Guix/стандартный Linux) fn cups_bin(name: &str) -> std::path::PathBuf { let candidates = [ format!("/pkg/gnu/cups/bin/{}", name), // Guix format!("/run/current-system/sw/bin/{}", name), // NixOS format!("/usr/bin/{}", name), format!("/usr/local/bin/{}", name), ]; for p in &candidates { let path = std::path::Path::new(p); if path.exists() { return path.to_path_buf(); } } // fallback — надеемся на PATH std::path::PathBuf::from(name) } fn normalize_format(format: &str) -> String { match format.trim() { "" | "Авто" | "auto" => String::new(), "10x15" => "10x15cm".to_string(), other => other.to_string(), } } fn run_lp(path: &Path, settings: Option<&PrintSettings>) -> Result { let lp = cups_bin("lp"); let mut cmd = Command::new(&lp); if let Some(s) = settings { cmd.arg("-n").arg(s.copies.to_string()); if s.pages != "all" && !s.pages.is_empty() { cmd.arg("-P").arg(&s.pages); } if s.color_mode == "bw" { cmd.arg("-o").arg("ColorModel=Gray"); } let fmt = normalize_format(&s.format); if !fmt.is_empty() { cmd.arg("-o").arg(format!("PageSize={}", fmt)); } if s.sides.as_deref() == Some("Двусторонняя") { cmd.arg("-o").arg("sides=two-sided-long-edge"); } match s.dpi.as_deref() { Some(d) if !d.is_empty() && d != "Авто" => { cmd.arg("-o").arg(format!("printer-resolution={}dpi", d)); } _ => {} } } cmd.arg(path); let args: Vec = cmd .get_args() .map(|a| a.to_string_lossy().to_string()) .collect(); info!( command = format!("{} {}", lp.display(), args.join(" ")), "🖨️ Выполнение команды печати" ); let out = cmd .output() .map_err(|e| format!("Не удалось запустить lp: {}", e))?; if !out.status.success() { return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); } // stdout: "request id is PRINTER-123 (1 file(s))" let stdout = String::from_utf8_lossy(&out.stdout).to_string(); Ok(stdout .split_whitespace() .nth(3) .unwrap_or("") .to_string()) } fn lpstat_raw(which: &str) -> Option { let lpstat = cups_bin("lpstat"); Command::new(&lpstat) .arg("-W") .arg(which) .arg("-o") .output() .ok() .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) } fn has_job_in(output: &Option, id: &str) -> bool { output .as_deref() .map(|s| { s.lines() .any(|l| l.split_whitespace().next() == Some(id)) }) .unwrap_or(false) } /// Ждём, пока CUPS-job исчезнет из не-завершённых. /// «Не завершён» = всё, что в состоянии queued / active / held (принтер не ready). fn wait_for_cups(id: &str) -> Result<(), String> { let lpstat_path = cups_bin("lpstat"); // Если lpstat физически отсутствует — не можем отслеживать, возвращаем ошибку if !lpstat_path.exists() && lpstat_path.to_str().unwrap_or("") == "lpstat" { // пробуем найти через which if Command::new("which") .arg("lpstat") .output() .map(|o| !o.status.success()) .unwrap_or(true) { return Err( "lpstat не найден — не могу отслеживать статус печати в CUPS".into(), ); } } let started = Instant::now(); let timeout = Duration::from_secs(30 * 60); // 30 минут максимум // Первый опрос: проверяем что lpstat вообще работает let probe = lpstat_raw("not-completed"); if probe.is_none() { return Err("lpstat не отвечает (возможно, не запущен cupsd)".into()); } loop { // "not-completed" = все задания, которые ещё не finished/cancelled // Сюда попадают: pending, held, processing, а также "not ready" задания let not_completed = lpstat_raw("not-completed"); let in_queue = has_job_in(¬_completed, id); if !in_queue { // Либо допечаталось (в completed), либо отменили — в любом случае наша работа сделана info!(cups_id = %id, "✔ CUPS-задание покинуло очередь (допечатано/отменено)"); return Ok(()); } if started.elapsed() > timeout { return Err("Таймаут ожидания завершения печати (30 мин)".into()); } std::thread::sleep(Duration::from_millis(1500)); } }