summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main.rs240
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 @@
1use 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};
6use base64::{Engine, engine::general_purpose::STANDARD as B64};
7use clap::Parser;
8use hyper_util::{rt::TokioIo, server::conn::auto::Builder, service::TowerToHyperService};
9use rustls::ServerConfig;
10use std::{io::Write, path::{Path as FsPath, PathBuf}, sync::Arc, time::Instant};
11use tokio::{fs, io::AsyncWriteExt, net::TcpListener, signal};
12use tokio_rustls::TlsAcceptor;
13
14type St = Arc<AppState>;
15struct AppState { root: PathBuf, pass: Option<String> }
16
17#[derive(Parser)]
18struct 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]
25async 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
64fn 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
99async 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
107async 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
121fn upath(p: &Option<Path<String>>) -> &str { p.as_ref().map(|p| p.as_str()).unwrap_or("") }
122
123fn 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
130async 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
138async 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
174async 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
184async 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>
214const dz=document.getElementById('dz'),fi=document.getElementById('fi'),cb=document.getElementById('cb'),st=document.getElementById('st');
215let xhrs=[];
216dz.ondragover=e=>{{e.preventDefault();dz.classList.add('over')}};
217dz.ondragleave=()=>dz.classList.remove('over');
218dz.ondrop=e=>{{e.preventDefault();dz.classList.remove('over');if(e.dataTransfer.files.length)up(e.dataTransfer.files)}};
219function 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'}}
220function 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';
222Promise.all([...F].map((f,i)=>new Promise((ok,no)=>{{const x=new XMLHttpRequest(),fd=new FormData();fd.append('file',f);xhrs.push(x);
223x.open('POST',location.pathname);
224x.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`}}}};
225x.onload=()=>x.status>=400?no(Error(f.name+':'+x.status)):ok();
226x.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`;
228setTimeout(()=>location.reload(),1000)}}).catch(e=>{{cb.style.display='none';st.textContent=e.message}})}}
229</script></body></html>"#)).into_response()
230}
231
232fn sanitize(n: &str) -> String {
233 FsPath::new(n).file_name().map(|n| n.to_string_lossy().replace(['/', '\\', '\0'], "_")).unwrap_or_default()
234}
235fn esc(s: &str) -> String { s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }
236fn 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}