use anyhow::Result;
use axum::{
Form, Json, Router,
body::Body,
extract::{DefaultBodyLimit, State},
response::{IntoResponse, Response},
routing::{get, post},
};
use headers::HeaderMapExt;
use hyper::{
StatusCode,
header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderValue},
};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use tokio::runtime::Runtime;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::Level;
use tracing::{error, info};
use std::net::SocketAddr;
use std::sync::{Arc, LazyLock};
use crate::cli::Source;
use crate::message::scan_job::{ColorSpace, Format};
use crate::message::scan_status::ScannerState;
use crate::scanner::{self, Scanner, ScannerError};
use crate::util::scan_to_stream;
use crate::web::static_content::StaticContent;
mod static_content;
const ERROR_TEMPLATE: &str = include_str!("../resources/error.html");
const TEXT_HTML: &str = "text/html";
const TEXT_JS: &str = "text/javascript";
const TEXT_CSS: &str = "text/css";
const IMAGE_ICON: &str = "image/x-icon";
static INDEX_HTML: LazyLock<StaticContent> =
LazyLock::new(|| StaticContent::from_str(include_str!("../resources/index.html"), TEXT_HTML));
static INDEX_JS: LazyLock<StaticContent> =
LazyLock::new(|| StaticContent::from_str(include_str!("../resources/index.js"), TEXT_JS));
static STYLE_CSS: LazyLock<StaticContent> =
LazyLock::new(|| StaticContent::from_str(include_str!("../resources/style.css"), TEXT_CSS));
static FAVICON: LazyLock<StaticContent> =
LazyLock::new(|| StaticContent::new(include_bytes!("../resources/favicon.ico"), IMAGE_ICON));
pub fn run_server(
scanner_host: &str,
listen_addr: &str,
listen_port: u16,
use_tls: bool,
disable_jpeg_fix: bool,
) -> Result<()> {
let addr = SocketAddr::new(listen_addr.parse()?, listen_port);
info!("Running on http://{listen_addr}:{listen_port}/");
let scanner = Scanner::new(scanner_host, use_tls, disable_jpeg_fix);
let rt = Runtime::new()?;
rt.block_on(run_server_async(addr, scanner))
}
async fn run_server_async(addr: SocketAddr, scanner: Scanner) -> Result<()> {
let scanner = Arc::new(scanner);
let app = Router::new()
.route("/", get(index))
.route("/style.css", get(style_css))
.route("/index.js", get(index_js))
.route("/favicon.ico", get(favicon))
.route("/scan", post(handle_scan_form))
.route("/status", get(status))
.layer(DefaultBodyLimit::max(1024 * 32))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new().level(Level::INFO))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
)
.with_state(scanner);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn index(headers: HeaderMap) -> impl IntoResponse {
INDEX_HTML.get_request(headers.typed_get())
}
async fn style_css(headers: HeaderMap) -> impl IntoResponse {
STYLE_CSS.get_request(headers.typed_get())
}
async fn index_js(headers: HeaderMap) -> impl IntoResponse {
INDEX_JS.get_request(headers.typed_get())
}
async fn favicon(headers: HeaderMap) -> impl IntoResponse {
FAVICON.get_request(headers.typed_get())
}
#[derive(Deserialize, Debug)]
struct ScanInput {
format: Option<Format>,
colorspace: Option<ColorSpace>,
source: Option<Source>,
quality: Option<QualityProfile>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
enum QualityProfile {
Base,
High,
Best,
}
impl QualityProfile {
fn resolution(&self) -> u32 {
match self {
Self::Base => 300,
Self::High => 600,
Self::Best => 600,
}
}
fn quality(&self) -> u32 {
match self {
Self::Base => 25,
Self::High => 25,
Self::Best => 1,
}
}
}
async fn handle_scan_form(
State(scanner): State<Arc<Scanner>>,
Form(input): Form<ScanInput>,
) -> impl IntoResponse {
let format = input.format.unwrap_or(Format::Pdf);
let color = input.colorspace.unwrap_or(ColorSpace::Color);
let source = input.source.unwrap_or(Source::Auto);
let quality = input.quality.unwrap_or(QualityProfile::Base);
let resolution = quality.resolution();
let quality = quality.quality();
info!(
"Scan parameters: format={format:?}, color={color:?}, source={source:?}, resolution={resolution}, quality={quality}"
);
let stream = match scan_to_stream(&scanner, format, color, source, resolution, quality).await {
Ok(s) => s,
Err(e) => return render_error(&e),
};
let mut response = Response::new(Body::from_stream(stream));
response
.headers_mut()
.insert(CONTENT_TYPE, content_type(format));
let filename = scanner::output_file_name(format, &Timestamp::now());
response
.headers_mut()
.insert(CONTENT_DISPOSITION, content_disposition(&filename));
response
}
#[derive(Serialize)]
struct StatusResponse {
status: Status,
message: String,
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
enum Status {
Idle,
Busy,
Error,
}
async fn status(State(scanner): State<Arc<Scanner>>) -> Json<StatusResponse> {
let (status, message) = match scanner.get_scan_status().await {
Ok(status) => match status.scanner_state() {
ScannerState::Idle => (Status::Idle, "idle".to_owned()),
ScannerState::BusyWithScanJob => (Status::Busy, "busy".to_owned()),
ScannerState::AdfError => (Status::Error, "ADF error".to_owned()),
},
Err(ScannerError::NotAvailable { source: _ }) => {
(Status::Error, "not available".to_owned())
}
Err(e) => (Status::Error, e.to_string()),
};
Json(StatusResponse { status, message })
}
fn content_type(format: Format) -> HeaderValue {
match format {
Format::Pdf => HeaderValue::from_static("application/pdf"),
Format::Jpeg => HeaderValue::from_static("image/jpeg"),
}
}
fn content_disposition(filename: &str) -> HeaderValue {
let mut value = "attachment; ".to_owned();
if filename.is_ascii() {
value += "filename=";
} else {
value += "filename*=utf-8''";
}
value += filename;
HeaderValue::from_str(&value).expect("valid header value")
}
fn render_error(error: &ScannerError) -> Response<Body> {
match *error {
ScannerError::AdfEmpty => error_page("ADF is empty"),
ScannerError::Busy => error_page("Scanner is busy"),
ScannerError::NotAvailable { ref source } => {
error_page(format!("{error}<p>Cause: {source}</p>").as_str())
}
ScannerError::Parse {
ref source,
data: _,
} => error_page(format!("{error}<p>Cause: {source}</p>").as_str()),
ScannerError::Canceled => error_page("Scan cancelled"),
_ => {
error!("InternalServerError: Failed to scan. {error:?}");
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
response
}
}
}
fn error_page(error_message: &str) -> Response<Body> {
#[allow(clippy::all)]
let page = ERROR_TEMPLATE.replace("{error_message}", error_message);
let mut response = Response::new(Body::from(page));
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(TEXT_HTML));
response
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn generate_content_disposition() {
assert_eq!(
"attachment; filename=test.txt",
content_disposition("test.txt").to_str().unwrap()
);
assert_eq!(
"attachment; filename*=utf-8''äöü.txt",
String::from_utf8_lossy(content_disposition("äöü.txt").as_bytes())
);
}
}