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>
332 lines
11 KiB
Rust
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();
|
|
}
|