diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 240 |
1 files changed, 240 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..926ddb6 --- /dev/null +++ b/src/main.rs | |||
| @@ -0,0 +1,240 @@ | |||
| 1 | use axum::{ | ||
| 2 | body::Body, extract::{DefaultBodyLimit, Multipart, Path, Request, State}, | ||
| 3 | http::{header, StatusCode}, middleware::{self, Next}, | ||
| 4 | response::{Html, IntoResponse, Redirect, Response}, routing::get, Router, | ||
| 5 | }; | ||
| 6 | use base64::{Engine, engine::general_purpose::STANDARD as B64}; | ||
| 7 | use clap::Parser; | ||
| 8 | use hyper_util::{rt::TokioIo, server::conn::auto::Builder, service::TowerToHyperService}; | ||
| 9 | use rustls::ServerConfig; | ||
| 10 | use std::{io::Write, path::{Path as FsPath, PathBuf}, sync::Arc, time::Instant}; | ||
| 11 | use tokio::{fs, io::AsyncWriteExt, net::TcpListener, signal}; | ||
| 12 | use tokio_rustls::TlsAcceptor; | ||
| 13 | |||
| 14 | type St = Arc<AppState>; | ||
| 15 | struct AppState { root: PathBuf, pass: Option<String> } | ||
| 16 | |||
| 17 | #[derive(Parser)] | ||
| 18 | struct Args { | ||
| 19 | #[arg(short, long, default_value_t = 8000)] port: u16, | ||
| 20 | #[arg(default_value = ".")] directory: PathBuf, | ||
| 21 | #[arg(long)] pass: Option<String>, | ||
| 22 | } | ||
| 23 | |||
| 24 | #[tokio::main] | ||
| 25 | async fn main() { | ||
| 26 | let args = Args::parse(); | ||
| 27 | let root = std::fs::canonicalize(&args.directory) | ||
| 28 | .unwrap_or_else(|_| panic!("Not found: {}", args.directory.display())); | ||
| 29 | let st: St = Arc::new(AppState { root: root.clone(), pass: args.pass }); | ||
| 30 | let app = Router::new() | ||
| 31 | .route("/", get(get_h).post(upload_h)) | ||
| 32 | .route("/{*path}", get(get_h).post(upload_h)) | ||
| 33 | .layer(DefaultBodyLimit::max(100 * 1024 * 1024 * 1024)) | ||
| 34 | .layer(middleware::from_fn_with_state(st.clone(), auth)) | ||
| 35 | .with_state(st); | ||
| 36 | let acceptor = self_signed_tls(); | ||
| 37 | let addr = format!("[::]:{}", args.port); | ||
| 38 | let listener = TcpListener::bind(&addr).await.unwrap(); | ||
| 39 | println!("Serving {} on https://{addr}", root.display()); | ||
| 40 | |||
| 41 | let shutdown = async { signal::ctrl_c().await.ok(); println!("\nshutting down..."); }; | ||
| 42 | tokio::pin!(shutdown); | ||
| 43 | |||
| 44 | loop { | ||
| 45 | tokio::select! { | ||
| 46 | _ = &mut shutdown => break, | ||
| 47 | res = listener.accept() => { | ||
| 48 | let (stream, _peer) = match res { | ||
| 49 | Ok(c) => c, Err(e) => { eprintln!("accept: {e}"); continue } | ||
| 50 | }; | ||
| 51 | let (acc, app) = (acceptor.clone(), app.clone()); | ||
| 52 | tokio::spawn(async move { | ||
| 53 | let Ok(tls) = acc.accept(stream).await.inspect_err(|e| eprintln!("tls: {e}")) else { return }; | ||
| 54 | let svc = TowerToHyperService::new(app); | ||
| 55 | let _ = Builder::new(hyper_util::rt::TokioExecutor::new()) | ||
| 56 | .serve_connection(TokioIo::new(tls), svc).await; | ||
| 57 | }); | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | |||
| 64 | fn self_signed_tls() -> TlsAcceptor { | ||
| 65 | use std::os::unix::fs::MetadataExt; | ||
| 66 | let dir = std::path::Path::new("/tmp/fileserver"); | ||
| 67 | let uid = unsafe { libc::getuid() }; | ||
| 68 | if dir.exists() { | ||
| 69 | let meta = std::fs::metadata(dir).expect("can't stat /tmp/fileserver"); | ||
| 70 | if meta.uid() != uid { | ||
| 71 | eprintln!("/tmp/fileserver owned by uid {}, expected {uid} — refusing", meta.uid()); | ||
| 72 | std::process::exit(1); | ||
| 73 | } | ||
| 74 | } else { | ||
| 75 | std::fs::create_dir(dir).expect("can't create /tmp/fileserver"); | ||
| 76 | } | ||
| 77 | let lock = std::fs::File::create("/tmp/fileserver/.lock").unwrap(); | ||
| 78 | use std::os::unix::io::AsRawFd; | ||
| 79 | if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { | ||
| 80 | eprintln!("another fileserver is already running"); std::process::exit(1); | ||
| 81 | } | ||
| 82 | std::mem::forget(lock); | ||
| 83 | for entry in std::fs::read_dir(dir).unwrap() { | ||
| 84 | let p = entry.unwrap().path(); | ||
| 85 | if p.file_name().unwrap() != ".lock" { let _ = std::fs::remove_file(&p); } | ||
| 86 | } | ||
| 87 | |||
| 88 | let c = rcgen::generate_simple_self_signed(vec!["localhost".into(), "0.0.0.0".into(), "127.0.0.1".into()]) | ||
| 89 | .expect("cert gen failed"); | ||
| 90 | let (cp, kp) = (c.cert.pem(), c.signing_key.serialize_pem()); | ||
| 91 | std::fs::write("/tmp/fileserver/cert.pem", &cp).unwrap(); | ||
| 92 | std::fs::write("/tmp/fileserver/key.pem", &kp).unwrap(); | ||
| 93 | println!("cert: /tmp/fileserver/cert.pem\nkey: /tmp/fileserver/key.pem"); | ||
| 94 | let certs: Vec<_> = rustls_pemfile::certs(&mut cp.as_bytes()).collect::<Result<_, _>>().unwrap(); | ||
| 95 | let key = rustls_pemfile::private_key(&mut kp.as_bytes()).unwrap().unwrap(); | ||
| 96 | TlsAcceptor::from(Arc::new(ServerConfig::builder().with_no_client_auth().with_single_cert(certs, key).unwrap())) | ||
| 97 | } | ||
| 98 | |||
| 99 | async fn auth(State(st): State<St>, req: Request, next: Next) -> Response { | ||
| 100 | let method = req.method().clone(); | ||
| 101 | let uri = req.uri().path().to_string(); | ||
| 102 | let resp = auth_inner(&st, req, next).await; | ||
| 103 | println!("{method} {uri} {}", resp.status().as_u16()); | ||
| 104 | resp | ||
| 105 | } | ||
| 106 | |||
| 107 | async fn auth_inner(st: &AppState, req: Request, next: Next) -> Response { | ||
| 108 | let Some(pw) = &st.pass else { return next.run(req).await }; | ||
| 109 | let ok = req.headers().get(header::AUTHORIZATION) | ||
| 110 | .and_then(|v| v.to_str().ok()) | ||
| 111 | .and_then(|v| v.strip_prefix("Basic ")) | ||
| 112 | .and_then(|b| B64.decode(b).ok()) | ||
| 113 | .and_then(|b| String::from_utf8(b).ok()) | ||
| 114 | .is_some_and(|c| c.ends_with(&format!(":{pw}"))); | ||
| 115 | if ok { return next.run(req).await } | ||
| 116 | Response::builder().status(StatusCode::UNAUTHORIZED) | ||
| 117 | .header(header::WWW_AUTHENTICATE, r#"Basic realm="fileserver""#) | ||
| 118 | .body(Body::from("Unauthorized")).unwrap() | ||
| 119 | } | ||
| 120 | |||
| 121 | fn upath(p: &Option<Path<String>>) -> &str { p.as_ref().map(|p| p.as_str()).unwrap_or("") } | ||
| 122 | |||
| 123 | fn resolve(root: &FsPath, p: &str) -> Option<PathBuf> { | ||
| 124 | let d = percent_encoding::percent_decode_str(p).decode_utf8_lossy(); | ||
| 125 | let c = d.trim_start_matches('/'); | ||
| 126 | let f = if c.is_empty() { root.to_path_buf() } else { root.join(c) }; | ||
| 127 | f.canonicalize().ok().filter(|p| p.starts_with(root)) | ||
| 128 | } | ||
| 129 | |||
| 130 | async fn get_h(State(st): State<St>, path: Option<Path<String>>) -> Response { | ||
| 131 | let up = upath(&path); | ||
| 132 | let Some(p) = resolve(&st.root, up) else { return StatusCode::FORBIDDEN.into_response() }; | ||
| 133 | if p.is_dir() { dir_list(&p, up).await } | ||
| 134 | else if p.is_file() { serve_file(&p).await } | ||
| 135 | else { StatusCode::NOT_FOUND.into_response() } | ||
| 136 | } | ||
| 137 | |||
| 138 | async fn upload_h(State(st): State<St>, path: Option<Path<String>>, mut mp: Multipart) -> Response { | ||
| 139 | let up = upath(&path); | ||
| 140 | let Some(dir) = resolve(&st.root, up) else { return StatusCode::FORBIDDEN.into_response() }; | ||
| 141 | let dir = if dir.is_dir() { dir } else { dir.parent().unwrap_or(&st.root).to_path_buf() }; | ||
| 142 | while let Ok(Some(mut field)) = mp.next_field().await { | ||
| 143 | if field.name() != Some("file") { continue } | ||
| 144 | let name = match field.file_name() { Some(n) => sanitize(n), None => continue }; | ||
| 145 | if name.is_empty() { continue } | ||
| 146 | let dest = dir.join(&name); | ||
| 147 | if !dest.starts_with(&st.root) { return StatusCode::FORBIDDEN.into_response() } | ||
| 148 | println!("receiving: {}", dest.display()); | ||
| 149 | let t = Instant::now(); | ||
| 150 | let Ok(mut f) = fs::File::create(&dest).await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; | ||
| 151 | let (mut sz, mut last_print): (u64, u64) = (0, 0); | ||
| 152 | while let Ok(Some(ch)) = field.chunk().await { | ||
| 153 | if f.write_all(&ch).await.is_err() { | ||
| 154 | let _ = fs::remove_file(&dest).await; | ||
| 155 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 156 | } | ||
| 157 | sz += ch.len() as u64; | ||
| 158 | if sz - last_print >= 10 * 1024 * 1024 { | ||
| 159 | let el = t.elapsed().as_secs_f64(); | ||
| 160 | let spd = if el > 0.0 { (sz as f64 / el) as u64 } else { 0 }; | ||
| 161 | print!("\r {} received, {}/s", hsz(sz), hsz(spd)); | ||
| 162 | std::io::stdout().flush().ok(); | ||
| 163 | last_print = sz; | ||
| 164 | } | ||
| 165 | } | ||
| 166 | if last_print > 0 { println!() } | ||
| 167 | if sz == 0 { let _ = fs::remove_file(&dest).await; continue } | ||
| 168 | let el = t.elapsed().as_secs_f64(); | ||
| 169 | println!("done: {} ({} @ {}/s)", dest.display(), hsz(sz), hsz(if el > 0.0 { (sz as f64 / el) as u64 } else { 0 })); | ||
| 170 | } | ||
| 171 | Redirect::to(&if up.is_empty() { "/".into() } else { format!("/{up}") }).into_response() | ||
| 172 | } | ||
| 173 | |||
| 174 | async fn serve_file(p: &FsPath) -> Response { | ||
| 175 | let Ok(b) = fs::read(p).await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; | ||
| 176 | let mime = mime_guess::from_path(p).first_or_octet_stream().to_string(); | ||
| 177 | let name = p.file_name().unwrap().to_string_lossy(); | ||
| 178 | let inline = mime.starts_with("text/") || mime.starts_with("image/") || mime == "application/pdf"; | ||
| 179 | let d = format!("{}; filename=\"{name}\"", if inline { "inline" } else { "attachment" }); | ||
| 180 | Response::builder().header(header::CONTENT_TYPE, mime).header(header::CONTENT_DISPOSITION, d) | ||
| 181 | .body(Body::from(b)).unwrap() | ||
| 182 | } | ||
| 183 | |||
| 184 | async fn dir_list(dir: &FsPath, url_path: &str) -> Response { | ||
| 185 | let Ok(mut rd) = fs::read_dir(dir).await else { return StatusCode::FORBIDDEN.into_response() }; | ||
| 186 | let (mut ds, mut fs_): (Vec<(String, String)>, Vec<(String, String, u64)>) = (vec![], vec![]); | ||
| 187 | while let Ok(Some(e)) = rd.next_entry().await { | ||
| 188 | let n = e.file_name().to_string_lossy().into_owned(); | ||
| 189 | let Ok(m) = e.metadata().await else { continue }; | ||
| 190 | let enc = percent_encoding::utf8_percent_encode(&n, percent_encoding::NON_ALPHANUMERIC).to_string(); | ||
| 191 | if m.is_dir() { ds.push((n, enc)) } else { fs_.push((n, enc, m.len())) } | ||
| 192 | } | ||
| 193 | ds.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); | ||
| 194 | fs_.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); | ||
| 195 | let dp = if url_path.is_empty() { "/" } else { url_path }; | ||
| 196 | let de = esc(dp); | ||
| 197 | let mut r = String::new(); | ||
| 198 | if !url_path.is_empty() { r.push_str("<a href=\"../\">../</a>\n") } | ||
| 199 | for (n, e) in &ds { r.push_str(&format!("<a href=\"{e}/\">{}/</a>\n", esc(n))) } | ||
| 200 | for (n, e, s) in &fs_ { | ||
| 201 | let (nm, sz) = (esc(n), hsz(*s)); | ||
| 202 | let pad = 60usize.saturating_sub(nm.len()).max(2); | ||
| 203 | r.push_str(&format!("<a href=\"{e}\">{nm}</a>{:>w$}\n", sz, w = pad + sz.len())); | ||
| 204 | } | ||
| 205 | Html(format!(r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>{de}</title> | ||
| 206 | <style>body{{font-family:monospace;margin:2em auto;max-width:900px;padding:0 1em}}a{{color:#07c}} | ||
| 207 | #dz{{margin-top:1em;padding:1em;border:2px dashed #ccc;text-align:center}} | ||
| 208 | #dz.over{{border-color:#07c;background:#eef}}</style></head> | ||
| 209 | <body><h2>{de}</h2><pre>{r}</pre> | ||
| 210 | <div id="dz">drop files | <input type="file" id="fi" multiple> <button onclick="up(fi.files)">upload</button> | ||
| 211 | <button id="cb" style="display:none;color:red" onclick="xhrs.forEach(x=>x.abort());xhrs=[];cb.style.display='none';st.textContent='cancelled'">cancel</button> | ||
| 212 | <span id="st"></span></div> | ||
| 213 | <script> | ||
| 214 | const dz=document.getElementById('dz'),fi=document.getElementById('fi'),cb=document.getElementById('cb'),st=document.getElementById('st'); | ||
| 215 | let xhrs=[]; | ||
| 216 | dz.ondragover=e=>{{e.preventDefault();dz.classList.add('over')}}; | ||
| 217 | dz.ondragleave=()=>dz.classList.remove('over'); | ||
| 218 | dz.ondrop=e=>{{e.preventDefault();dz.classList.remove('over');if(e.dataTransfer.files.length)up(e.dataTransfer.files)}}; | ||
| 219 | function hz(b){{for(const u of['B','KB','MB','GB','TB']){{if(b<1024)return(b|0)==b?b+' '+u:b.toFixed(1)+' '+u;b/=1024}}return b.toFixed(1)+' PB'}} | ||
| 220 | function up(F){{if(!F.length)return;let n=F.length,tot=0,t0=Date.now();xhrs=[];const ld=new Map(); | ||
| 221 | [...F].forEach(f=>tot+=f.size);st.textContent='...';cb.style.display='inline'; | ||
| 222 | Promise.all([...F].map((f,i)=>new Promise((ok,no)=>{{const x=new XMLHttpRequest(),fd=new FormData();fd.append('file',f);xhrs.push(x); | ||
| 223 | x.open('POST',location.pathname); | ||
| 224 | x.upload.onprogress=e=>{{if(e.lengthComputable){{ld.set(i,e.loaded);let s=0;ld.forEach(v=>s+=v);st.textContent=`${{hz(s)}}/${{hz(tot)}} ${{hz(s/((Date.now()-t0)/1000))}}/s`}}}}; | ||
| 225 | x.onload=()=>x.status>=400?no(Error(f.name+':'+x.status)):ok(); | ||
| 226 | x.onabort=()=>no(Error('cancelled'));x.onerror=()=>no(Error(f.name+':failed'));x.send(fd) | ||
| 227 | }}))).then(()=>{{cb.style.display='none';st.textContent=`done ${{n}} ${{hz(tot)}} @ ${{hz(tot/((Date.now()-t0)/1000))}}/s`; | ||
| 228 | setTimeout(()=>location.reload(),1000)}}).catch(e=>{{cb.style.display='none';st.textContent=e.message}})}} | ||
| 229 | </script></body></html>"#)).into_response() | ||
| 230 | } | ||
| 231 | |||
| 232 | fn sanitize(n: &str) -> String { | ||
| 233 | FsPath::new(n).file_name().map(|n| n.to_string_lossy().replace(['/', '\\', '\0'], "_")).unwrap_or_default() | ||
| 234 | } | ||
| 235 | fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } | ||
| 236 | fn hsz(b: u64) -> String { | ||
| 237 | let mut s = b as f64; | ||
| 238 | for u in ["B","KB","MB","GB","TB"] { if s < 1024.0 { return if u=="B" { format!("{s:.0} {u}") } else { format!("{s:.1} {u}") } } s /= 1024.0 } | ||
| 239 | format!("{s:.1} PB") | ||
| 240 | } | ||
