mod html; use axum::extract::{DefaultBodyLimit, Multipart, Path, State}; use axum::http::{header, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; use std::net::SocketAddr; const CAN_API: &str = "http://127.0.0.1:3210/api/v1/can/0"; #[derive(Clone)] struct AppState { client: reqwest::Client, } #[derive(Deserialize)] struct PasteTextRequest { text: String, } // ── Helpers ────────────────────────────────────────────────────────────── /// Extract #hashtags from text, returning the comma-separated tag string. /// e.g. "some #chicken and #food" -> "chicken,food" fn extract_tags(text: &str) -> String { text.split_whitespace() .filter(|w| w.starts_with('#') && w.len() > 1) .map(|w| w[1..].trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')) .filter(|t| !t.is_empty()) .collect::>() .join(",") } /// Convert a reqwest response into an axum response, copying status + /// content-type + body. Intentionally drops Content-Disposition so that /// images render inline rather than triggering a download. async fn forward(resp: Result) -> Response { match resp { Ok(r) => { let status = StatusCode::from_u16(r.status().as_u16()) .unwrap_or(StatusCode::BAD_GATEWAY); let ct = r .headers() .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); let bytes = r.bytes().await.unwrap_or_default(); (status, [(header::CONTENT_TYPE, ct)], bytes).into_response() } Err(e) => ( StatusCode::BAD_GATEWAY, format!("CanService unreachable: {e}"), ) .into_response(), } } // ── Handlers ───────────────────────────────────────────────────────────── async fn serve_index() -> Html<&'static str> { Html(html::INDEX_HTML) } /// Accept `{ "text": "..." }` from the frontend, forward to CanService as /// a multipart text/plain file so the stored content is raw text (not /// JSON-wrapped). async fn paste_text( State(state): State, Json(body): Json, ) -> Response { let desc = if body.text.len() > 200 { format!("{}...", &body.text[..body.text.char_indices().nth(200).map(|(i, _)| i).unwrap_or(body.text.len())]) } else { body.text.clone() }; let tags = extract_tags(&desc); let part = match reqwest::multipart::Part::bytes(body.text.into_bytes()) .file_name("paste.txt") .mime_str("text/plain") { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let mut form = reqwest::multipart::Form::new() .part("file", part) .text("application", "paste") .text("description", desc); if !tags.is_empty() { form = form.text("tags", tags); } let resp = state .client .post(format!("{CAN_API}/ingest")) .multipart(form) .send() .await; forward(resp).await } /// Accept a multipart upload from the frontend (clipboard image). /// Re-packages it into a new multipart request for CanService. async fn paste_file( State(state): State, mut multipart: Multipart, ) -> Response { let mut file_bytes: Option> = None; let mut file_name = "clipboard.png".to_string(); let mut content_type = "image/png".to_string(); let mut description = String::new(); loop { match multipart.next_field().await { Ok(Some(field)) => { let name = field.name().unwrap_or("").to_string(); match name.as_str() { "file" => { if let Some(fname) = field.file_name() { file_name = fname.to_string(); } if let Some(ct) = field.content_type() { content_type = ct.to_string(); } match field.bytes().await { Ok(b) => file_bytes = Some(b.to_vec()), Err(e) => { return (StatusCode::BAD_REQUEST, format!("Failed to read file: {e}")).into_response(); } } } "description" => { description = field.text().await.unwrap_or_default(); } _ => {} } } Ok(None) => break, Err(e) => { return (StatusCode::BAD_REQUEST, format!("Multipart error: {e}")).into_response(); } } } let Some(bytes) = file_bytes else { return (StatusCode::BAD_REQUEST, "Missing file field").into_response(); }; let part = match reqwest::multipart::Part::bytes(bytes) .file_name(file_name) .mime_str(&content_type) { Ok(p) => p, Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; let tags = extract_tags(&description); let mut form = reqwest::multipart::Form::new() .part("file", part) .text("application", "paste"); if !description.is_empty() { form = form.text("description", description); } if !tags.is_empty() { form = form.text("tags", tags); } let resp = state .client .post(format!("{CAN_API}/ingest")) .multipart(form) .send() .await; forward(resp).await } /// List items with application=paste, newest first. async fn paste_list(State(state): State) -> Response { let resp = state .client .get(format!( "{CAN_API}/list?application=paste&order=desc&limit=100" )) .send() .await; forward(resp).await } /// Proxy asset download by hash. async fn proxy_asset( State(state): State, Path(hash): Path, ) -> Response { let resp = state .client .get(format!("{CAN_API}/asset/{hash}")) .send() .await; forward(resp).await } /// Proxy thumbnail (200x200) by hash. async fn proxy_thumb( State(state): State, Path(hash): Path, ) -> Response { let resp = state .client .get(format!("{CAN_API}/asset/{hash}/thumb/200/200")) .send() .await; forward(resp).await } // ── Main ───────────────────────────────────────────────────────────────── #[tokio::main] async fn main() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "paste=info".into()), ) .init(); let state = AppState { client: reqwest::Client::new(), }; let app = Router::new() .route("/", get(serve_index)) .route("/paste/text", post(paste_text)) .route("/paste/file", post(paste_file)) .route("/paste/list", get(paste_list)) .route("/paste/asset/{hash}", get(proxy_asset)) .route("/paste/thumb/{hash}", get(proxy_thumb)) .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) // 100 MB .with_state(state); let addr = SocketAddr::from(([127, 0, 0, 1], 3211)); tracing::info!("paste running at http://{addr}"); tracing::info!("requires CanService at http://127.0.0.1:3210"); // Open browser (best-effort, won't crash on headless) let url = format!("http://{addr}"); let _ = open::that(&url); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); }