diff --git a/src/main.rs b/src/main.rs index 9284fc6..8a35227 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,27 @@ use axum::{ - extract::{DefaultBodyLimit, Multipart}, + extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State}, http::StatusCode, - response::IntoResponse, - routing::post, - Router, + routing::{get, post}, + Json, Router, }; - -use serde::Deserialize; -use std::process::Command; -use tempfile::NamedTempFile; +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}; +// ─────────────────────────────── Модели ─────────────────────────────── + #[derive(Deserialize, Clone, Debug)] struct PrintSettings { filename: String, @@ -21,8 +30,95 @@ struct PrintSettings { #[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() @@ -32,280 +128,471 @@ async fn main() { .with_line_number(true) .init(); + let store: JobStore = Arc::new(Mutex::new(HashMap::new())); let cors = CorsLayer::permissive(); let app = Router::new() - .route("/print", post(handle_print)) + .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); + .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 handle_print(mut multipart: Multipart) -> impl IntoResponse { - info!("📥 Получен запрос на печать"); +// ─────────────────────────── Обработчики ─────────────────────────── - let mut printed_files: Vec = Vec::new(); - let mut errors: Vec = Vec::new(); +async fn create_print( + State(store): State, + mut multipart: Multipart, +) -> Result, (StatusCode, String)> { + info!("📥 Получен запрос на печать (создание задания)"); - let mut settings_map: std::collections::HashMap = - std::collections::HashMap::new(); + let mut settings_map: HashMap = HashMap::new(); let mut files_data: Vec<(String, bytes::Bytes)> = Vec::new(); - // ── Парсинг multipart ────────────────────────────────────────────── 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) => { - info!(raw_settings = %text, "Получены настройки печати"); - match serde_json::from_str::(&text) { - Ok(settings) => { - info!( - filename = %settings.filename, - pages = %settings.pages, - copies = settings.copies, - color_mode = %settings.color_mode, - format = %settings.format, - "✅ Настройки распарсены" - ); - settings_map.insert(settings.filename.clone(), settings); - } - Err(e) => { - error!(raw_text = %text, error = %e, "❌ Не удалось распарсить settings JSON"); - errors.push(format!("Ошибка парсинга settings: {}", e)); - } + Ok(text) => match serde_json::from_str::(&text) { + Ok(s) => { + info!(filename = %s.filename, "✅ Настройки распарсены"); + settings_map.insert(s.filename.clone(), s); } - } - Err(e) => { - error!(error = %e, "❌ Не удалось прочитать поле settings"); - errors.push(format!("Ошибка чтения settings: {}", e)); - } + 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(), - "📎 Получен файл" - ); + info!(file_name = %file_name, size_bytes = data.len(), "📎 Получен файл"); files_data.push((file_name, data)); } - Err(e) => { - error!(file_name = %file_name, error = %e, "❌ Не удалось прочитать файл"); - errors.push(format!("Ошибка чтения файла {}: {}", file_name, e)); - } + Err(e) => error!(file_name = %file_name, error = %e, "❌ Ошибка чтения файла"), } } else { warn!(field_name = %field_name, "⚠️ Неизвестное поле в multipart, пропущено"); } } - info!( - total_files = files_data.len(), - total_settings = settings_map.len(), - "📊 Итоги парсинга multipart" - ); - if files_data.is_empty() { - error!("❌ В запросе нет файлов для печати"); - return ( + return Err(( StatusCode::BAD_REQUEST, - "В запросе отсутствуют файлы для печати".to_string(), - ); + "В запросе отсутствуют файлы для печати".into(), + )); } - // ── Обработка каждого файла ──────────────────────────────────────── + // Спулим каждый файл во временный (TempPath живёт вместе с job) + let mut job_files: Vec = Vec::new(); for (file_name, data) in files_data { - info!(file_name = %file_name, "🔄 Начало обработки файла"); - - // Нормализация расширения - let ext = std::path::Path::new(&file_name) + let ext = Path::new(&file_name) .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); - - info!(file_name = %file_name, extension = %ext, "Определено расширение файла"); - - // Создание временного файла - let temp_file = if ext.is_empty() { + let temp = if ext.is_empty() { NamedTempFile::new() } else { NamedTempFile::with_suffix(&format!(".{}", ext)) }; - - let temp_file = match temp_file { + let temp = match temp { Ok(f) => f, Err(e) => { error!(file_name = %file_name, error = %e, "❌ Не удалось создать temp файл"); - errors.push(format!("Не удалось создать temp файл для {}: {}", file_name, e)); continue; } }; - - let path = temp_file.path().to_path_buf(); - info!( - file_name = %file_name, - temp_path = %path.display(), - "📝 Временный файл создан" - ); - - // Запись данных во временный файл - if let Err(e) = fs::write(&path, &data).await { - error!( - file_name = %file_name, - temp_path = %path.display(), - error = %e, - "❌ Ошибка записи во временный файл" - ); - errors.push(format!("Ошибка записи {}: {}", file_name, e)); + let path = temp.into_temp_path(); + if let Err(e) = fs::write(&*path, &data).await { + error!(file_name = %file_name, error = %e, "❌ Ошибка записи во временный файл"); continue; } - - info!( - file_name = %file_name, - temp_path = %path.display(), - bytes_written = data.len(), - "✅ Файл записан во временную директорию" - ); - - // Поиск настроек для данного файла let settings = settings_map.get(&file_name).cloned(); if settings.is_none() { - warn!( - file_name = %file_name, - "⚠️ Настройки печати не найдены для файла, используются значения по умолчанию" - ); + warn!(file_name = %file_name, "⚠️ Настройки не найдены, печать по умолчанию"); } + job_files.push(JobFile { + name: file_name, + path, + settings, + status: FileStatus::Queued, + error: None, + }); + } - // Клонируем file_name для использования после spawn_blocking - let file_name_for_log = file_name.clone(); + if job_files.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "Не удалось сохранить ни один файл".into(), + )); + } - // Формирование и выполнение команды lp - let print_result = tokio::task::spawn_blocking(move || { - let mut cmd = Command::new("lp"); + 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)) +} - if let Some(ref s) = settings { - cmd.arg("-n").arg(s.copies.to_string()); +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))) +} - 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"); - } - - cmd.arg("-o").arg(format!("PageSize={}", s.format)); +/// Подтверждение клиента: старт первого файла ИЛИ «документ забран, печатай следующий» +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)) +} - cmd.arg(&path); - - // Логируем итоговую команду - let args: Vec = cmd - .get_args() - .map(|a| a.to_string_lossy().to_string()) - .collect(); - info!( - file_name = %file_name, - command = format!("lp {}", args.join(" ")), - "🖨️ Выполнение команды печати" - ); - - cmd.output() +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())) +} - // Обработка результата — используем file_name_for_log вместо file_name - match print_result { - Ok(Ok(output)) if output.status.success() => { - let stdout = String::from_utf8_lossy(&output.stdout); - info!( - file_name = %file_name_for_log, - stdout = %stdout.trim(), - "✅ Файл успешно отправлен на печать" - ); - printed_files.push(file_name_for_log); - } - Ok(Ok(output)) => { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - error!( - file_name = %file_name_for_log, - exit_code = ?output.status.code(), - stderr = %stderr.trim(), - stdout = %stdout.trim(), - "❌ Команда lp завершилась с ошибкой" - ); - errors.push(format!( - "lp ошибка для {}: {}", - file_name_for_log, - stderr.trim() - )); - } - Ok(Err(e)) => { - error!( - file_name = %file_name_for_log, - error = %e, - "❌ Не удалось запустить процесс lp" - ); - errors.push(format!( - "Не удалось запустить lp для {}: {}", - file_name_for_log, e - )); - } +// ─────────────────────── Печать: 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) => { - error!( - file_name = %file_name_for_log, - error = %e, - "❌ Ошибка join blocking task" - ); - errors.push(format!( - "Ошибка выполнения задачи для {}: {}", - file_name_for_log, 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(), + ); } } - // ── Формирование ответа ──────────────────────────────────────────── - info!( - printed_count = printed_files.len(), - error_count = errors.len(), - "📋 Итоговый результат обработки запроса" - ); + let started = Instant::now(); + let timeout = Duration::from_secs(30 * 60); // 30 минут максимум - if errors.is_empty() && !printed_files.is_empty() { - info!(files = ?printed_files, "🎉 Все файлы успешно напечатаны"); - ( - StatusCode::OK, - format!("Напечатано {} файлов", printed_files.len()), - ) - } else if printed_files.is_empty() { - error!(errors = ?errors, "💥 Все файлы не удалось напечатать"); - ( - StatusCode::BAD_REQUEST, - format!("Ошибки: {:?}", errors), - ) - } else { - warn!( - printed = ?printed_files, - errors = ?errors, - "⚠️ Частичная печать: некоторые файлы не напечатаны" - ); - ( - StatusCode::MULTI_STATUS, - format!("Частично: {:?}, Ошибки: {:?}", printed_files, errors), - ) + // Первый опрос: проверяем что 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)); } }