Jason Tudisco 360ecbdad0 Initial commit: CAN Service + examples (can-sync v1, canfs, filemanager, paste)
CAN Service: content-addressable storage with HTTP API, SQLite metadata,
file-based blob storage, thumbnail generation, and integrity verification.

can-sync v1: P2P sync sidecar using iroh-docs for encrypted peer-to-peer
replication with library/filter-based selective sync. Fully builds but
being superseded by v2 (simplified full-mirror approach).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:32:04 -06:00

264 lines
8.2 KiB
Rust

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::<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 ─────────────────────────────────────────────────────────────
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
}
// ── 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();
}