Files
backend-local/src/main.rs

668 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
#[serde(default)]
dpi: Option<String>,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum FileStatus {
Queued,
Printing,
AwaitingFlip,
Awaiting,
Done,
Error,
Cancelled,
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum JobPhase {
WaitingStart,
Printing,
AwaitingFlip,
AwaitingPickup,
Finished,
Cancelled,
}
#[derive(Clone, Debug, Serialize)]
struct JobFileView {
name: String,
status: FileStatus,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
/// true → нечётное кол-во страниц, нужно убрать верхний лист перед 2-м проходом
#[serde(default)]
remove_top_sheet: bool,
}
#[derive(Clone, Debug, Serialize)]
struct JobView {
job_id: String,
phase: JobPhase,
files: Vec<JobFileView>,
}
struct JobFile {
name: String,
path: TempPath, // живёт, пока жив job → автоочистка temp
settings: Option<PrintSettings>,
status: FileStatus,
error: Option<String>,
}
struct Job {
id: String,
files: Vec<JobFile>,
current: Option<usize>,
phase: JobPhase,
cups_id: Option<String>,
}
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(),
remove_top_sheet: Self::should_remove_top_sheet(&f.settings),
}
}).collect(),
}
}
/// Нечетных страниц больше, чем чётных → лишний лист сверху
fn should_remove_top_sheet(settings: &Option<PrintSettings>) -> bool {
let Some(s) = settings else { return false };
if s.sides.as_deref() != Some("Двусторонняя") { return false; }
if s.pages == "all" { return false; } // для офисных не знаем кол-во
let nums: Vec<u32> = s.pages.split(',')
.filter_map(|p| p.trim().parse().ok())
.collect();
let odd = nums.iter().filter(|n| *n % 2 == 1).count();
let even = nums.iter().filter(|n| *n % 2 == 0).count();
odd > even
}
}
#[derive(Clone)]
struct SpawnInfo {
job_id: String,
idx: usize,
path: PathBuf,
settings: Option<PrintSettings>,
is_second_pass: bool,
}
type JobStore = Arc<Mutex<HashMap<String, Job>>>;
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<JobStore>,
mut multipart: Multipart,
) -> Result<Json<JobView>, (StatusCode, String)> {
info!("📥 Получен запрос на печать (создание задания)");
let mut settings_map: HashMap<String, PrintSettings> = 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::<PrintSettings>(&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<JobFile> = 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<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (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<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (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::AwaitingFlip => {
if let Some(c) = job.current {
info!(job_id = %job_id, file = %job.files[c].name,
"🔄 Запуск второго прохода (чётные страницы)");
job.files[c].status = FileStatus::Printing;
job.phase = JobPhase::Printing;
job.cups_id = None;
Some(SpawnInfo {
job_id: job.id.clone(),
idx: c,
path: job.files[c].path.to_path_buf(),
settings: job.files[c].settings.clone(),
is_second_pass: true,
})
} else {
None
}
}
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 }
},
_ => 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<JobStore>,
AxumPath(job_id): AxumPath<String>,
) -> Result<Json<JobView>, (StatusCode, String)> {
let cups_ids: Vec<String> = {
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::<Vec<_>>();
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(),
is_second_pass: false,
}
}
fn begin_next_locked(job: &mut Job, from: usize) -> Option<SpawnInfo> {
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();
let is_duplex = settings.as_ref()
.map(|s| s.sides.as_deref() == Some("Двусторонняя"))
.unwrap_or(false);
let duplex_pass: Option<&str> = if is_duplex {
if info.is_second_pass { Some("even") } else { Some("odd") }
} else {
None
};
// 1) Спулим в CUPS
let p = path.clone();
let s = settings.clone();
let dp = duplex_pass.map(|d| d.to_string());
let spool = tokio::task::spawn_blocking(move || {
run_lp(&p, s.as_ref(), dp.as_deref())
}).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();
if let Some(job) = m.get_mut(&info.job_id) {
if job.phase != JobPhase::Cancelled {
job.cups_id = Some(cups_id.clone());
} else { return; }
} else { 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 { return; }
if job.files.get(info.idx).map(|f| f.status.clone()) != Some(FileStatus::Printing) { return; }
if is_duplex && !info.is_second_pass {
// Первый проход завершён → ждём, пока пользователь переложит бумагу
job.files[info.idx].status = FileStatus::AwaitingFlip;
job.phase = JobPhase::AwaitingFlip;
job.cups_id = None;
info!(job_id = %info.job_id, file = %job.files[info.idx].name,
"🔄 Первый проход завершён, ждём переворота бумаги");
} else {
// Обычная печать или второй проход дуплекса → ждём, пока заберут
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>, duplex_pass: Option<&str>) -> Result<String, String> {
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("Двусторонняя") && duplex_pass.is_none() {
cmd.arg("-o").arg("sides=two-sided-long-edge");
}
if let Some(d) = s.dpi.as_deref() {
if !d.is_empty() && d != "Авто" {
cmd.arg("-o").arg(format!("printer-resolution={}dpi", d));
}
}
}
// Ручной дуплекс: разбивка на чёт/нечёт
match duplex_pass {
Some("odd") => {
cmd.arg("-o").arg("page-set=odd");
}
Some("even") => {
cmd.arg("-o").arg("page-set=even");
cmd.arg("-o").arg("outputorder=reverse");
}
_ => {}
}
cmd.arg(path);
let args: Vec<String> = 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());
}
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<String> {
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<String>, 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(&not_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));
}
}