Jason Tudisco 620966872e Add plain-English comments to all functions across src/ and examples/
Comments help non-Rust users understand what each function, struct, and
module does. Covers the core service (18 source files) and all four
example projects (can-sync, canfs, filemanager, paste).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:35:24 -06:00

332 lines
11 KiB
Rust

mod html;
use axum::extract::{DefaultBodyLimit, Multipart, Path, State};
use axum::http::{header, StatusCode};
use axum::response::sse::{Event, Sse};
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use std::convert::Infallible;
use std::net::SocketAddr;
const CAN_API: &str = "http://127.0.0.1:3210/api/v1/can/0";
/// Shared HTTP client for talking to the CAN service backend.
#[derive(Clone)]
struct AppState {
client: reqwest::Client,
}
/// JSON body for the text paste endpoint.
#[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::<Vec<_>>()
.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<reqwest::Response, reqwest::Error>) -> 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 ─────────────────────────────────────────────────────────────
/// Serve the single-page HTML frontend.
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<AppState>,
Json(body): Json<PasteTextRequest>,
) -> 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<AppState>,
mut multipart: Multipart,
) -> Response {
let mut file_bytes: Option<Vec<u8>> = 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<AppState>) -> 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<AppState>,
Path(hash): Path<String>,
) -> 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<AppState>,
Path(hash): Path<String>,
) -> Response {
let resp = state
.client
.get(format!("{CAN_API}/asset/{hash}/thumb/200/200"))
.send()
.await;
forward(resp).await
}
/// Proxy SSE (Server-Sent Events) from the CAN service to the browser so
/// the frontend auto-refreshes when new pastes arrive.
async fn paste_events(
State(state): State<AppState>,
) -> Sse<impl futures_util::Stream<Item = Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
let client = state.client.clone();
tokio::spawn(async move {
loop {
match client.get(format!("{CAN_API}/events")).send().await {
Ok(resp) => {
use futures_util::StreamExt;
let mut stream = resp.bytes_stream();
let mut buf = String::new();
while let Some(chunk) = stream.next().await {
let Ok(bytes) = chunk else { break };
buf.push_str(&String::from_utf8_lossy(&bytes));
// Parse SSE frames (double-newline delimited)
while let Some(pos) = buf.find("\n\n") {
let frame = buf[..pos].to_string();
buf = buf[pos + 2..].to_string();
let mut event_type = None;
let mut data = None;
for line in frame.lines() {
if let Some(v) = line.strip_prefix("event: ") {
event_type = Some(v.to_string());
} else if let Some(v) = line.strip_prefix("data: ") {
data = Some(v.to_string());
}
// lines starting with ':' are SSE comments (keepalive) — skip
}
if let Some(d) = data {
let mut evt = Event::default().data(d);
if let Some(t) = event_type {
evt = evt.event(t);
}
if tx.send(Ok(evt)).await.is_err() {
return; // client disconnected
}
}
}
}
}
Err(e) => {
tracing::warn!("SSE proxy connect failed: {e}");
}
}
// Reconnect after a short delay
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
});
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
Sse::new(stream).keep_alive(
axum::response::sse::KeepAlive::new()
.interval(std::time::Duration::from_secs(15))
.text("ping"),
)
}
// ── Main ─────────────────────────────────────────────────────────────────
/// Start the Paste web app: a simple pastebin that stores text and images in CAN service.
#[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))
.route("/paste/events", get(paste_events))
.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();
}