1use crate::{color, gopher, Request, Result};
4use std::{
5 cmp::Ordering,
6 fs::{self, DirEntry},
7 io::{self, prelude::*, BufReader, Read, Write},
8 net::{SocketAddr, TcpListener, TcpStream},
9 os::unix::fs::PermissionsExt,
10 path::Path,
11 process::Command,
12 str,
13 sync::atomic::{AtomicBool, Ordering as AtomicOrdering},
14};
15use threadpool::ThreadPool;
16
17const MAX_WORKERS: usize = 10;
20
21const MAX_PEEK_SIZE: usize = 1024;
23
24const IGNORED_FILES: [&str; 3] = ["header.gph", "footer.gph", ".reverse"];
26
27static SHOW_INFO: AtomicBool = AtomicBool::new(true);
30
31fn hide_info() {
33 SHOW_INFO.swap(false, AtomicOrdering::Relaxed);
34}
35
36macro_rules! info {
38 ($e:expr) => {
39 if SHOW_INFO.load(AtomicOrdering::Relaxed) {
40 println!("{}", $e);
41 }
42 };
43 ($fmt:expr, $($args:expr),*) => {
44 info!(format!($fmt, $($args),*));
45 };
46 ($fmt:expr, $($args:expr,)*) => {
47 info!(format!($fmt, $($args,)*));
48 };
49}
50
51pub fn start(bind: SocketAddr, host: &str, port: u16, root: &str) -> Result<()> {
53 let listener = TcpListener::bind(&bind)?;
54 let full_root_path = fs::canonicalize(&root)?.to_string_lossy().to_string();
55 let pool = ThreadPool::new(MAX_WORKERS);
56
57 info!(
58 "{}» Listening {}on {}{}{} at {}{}{}",
59 color::Yellow,
60 color::Reset,
61 color::Yellow,
62 bind,
63 color::Reset,
64 color::Blue,
65 full_root_path,
66 color::Reset
67 );
68 for stream in listener.incoming() {
69 let stream = stream?;
70 info!(
71 "{}┌ Connection{} from {}{}",
72 color::Green,
73 color::Reset,
74 color::Magenta,
75 stream.peer_addr()?
76 );
77 let req = Request::from(host, port, root)?;
78 pool.execute(move || {
79 if let Err(e) = accept(stream, req) {
80 info!("{}└ {}{}", color::Red, e, color::Reset);
81 }
82 });
83 }
84 Ok(())
85}
86
87fn accept(mut stream: TcpStream, mut req: Request) -> Result<()> {
89 let reader = BufReader::new(&stream);
90 let mut lines = reader.lines();
91 if let Some(Ok(line)) = lines.next() {
92 info!(
93 "{}│{} Client sent:\t{}{:?}{}",
94 color::Green,
95 color::Reset,
96 color::Cyan,
97 line,
98 color::Reset
99 );
100 req.parse_request(&line);
101 write_response(&mut stream, req)?;
102 }
103 Ok(())
104}
105
106pub fn render(host: &str, port: u16, root: &str, selector: &str) -> Result<String> {
108 hide_info();
109 let mut req = Request::from(host, port, root)?;
110 req.parse_request(&selector);
111 let mut out = vec![];
112 write_response(&mut out, req)?;
113 Ok(String::from_utf8_lossy(&out).into())
114}
115
116fn write_response<W>(w: &mut W, mut req: Request) -> Result<()>
118where
119 W: Write,
120{
121 let path = req.file_path();
122
123 let mut gph_file = path.clone();
125 gph_file.push_str(".gph");
126 if fs_exists(&gph_file) {
127 req.selector = req.selector.trim_end_matches('/').into();
128 req.selector.push_str(".gph");
129 return write_gophermap(w, req);
130 } else {
131 let mut index = path.clone();
133 index.push_str("/index.gph");
134 if fs_exists(&index) {
135 req.selector.push_str("/index.gph");
136 return write_gophermap(w, req);
137 }
138 }
139
140 let meta = match fs::metadata(&path) {
141 Ok(meta) => meta,
142 Err(_) => return write_not_found(w, req),
143 };
144
145 if path.ends_with(".gph") {
146 write_gophermap(w, req)
147 } else if meta.is_file() {
148 write_file(w, req)
149 } else if meta.is_dir() {
150 write_dir(w, req)
151 } else {
152 Ok(())
153 }
154}
155
156fn write_dir<W>(w: &mut W, req: Request) -> Result<()>
158where
159 W: Write,
160{
161 let path = req.file_path();
162 if !fs_exists(&path) {
163 return write_not_found(w, req);
164 }
165
166 let mut header = path.clone();
167 header.push_str("/header.gph");
168 if fs_exists(&header) {
169 let mut sel = req.selector.clone();
170 sel.push_str("/header.gph");
171 write_gophermap(
172 w,
173 Request {
174 selector: sel,
175 ..req.clone()
176 },
177 )?;
178 }
179
180 let rel_path = req.relative_file_path();
181
182 let reverse = format!("{}/.reverse", path);
184 let paths = sort_paths(&path, fs_exists(&reverse))?;
185 for entry in paths {
186 let file_name = entry.file_name();
187 let f = file_name.to_string_lossy().to_string();
188 if f.chars().nth(0) == Some('.') || IGNORED_FILES.contains(&f.as_ref()) {
189 continue;
190 }
191 let path = format!(
192 "{}/{}",
193 rel_path.trim_end_matches('/'),
194 file_name.to_string_lossy()
195 );
196 write!(
197 w,
198 "{}{}\t{}\t{}\t{}\r\n",
199 file_type(&entry).to_char(),
200 &file_name.to_string_lossy(),
201 &path,
202 &req.host,
203 req.port,
204 )?;
205 }
206
207 let footer = format!("{}/footer.gph", path.trim_end_matches('/'));
208 if fs_exists(&footer) {
209 let sel = format!("{}/footer.gph", req.selector);
210 write_gophermap(
211 w,
212 Request {
213 selector: sel,
214 ..req.clone()
215 },
216 )?;
217 }
218
219 write!(w, ".\r\n");
220
221 info!(
222 "{}│{} Server reply:\t{}DIR {}{}{}",
223 color::Green,
224 color::Reset,
225 color::Yellow,
226 color::Bold,
227 req.relative_file_path(),
228 color::Reset,
229 );
230 Ok(())
231}
232
233fn write_file<W>(w: &mut W, req: Request) -> Result<()>
235where
236 W: Write,
237{
238 let path = req.file_path();
239 let mut f = fs::File::open(&path)?;
240 io::copy(&mut f, w)?;
241 info!(
242 "{}│{} Server reply:\t{}FILE {}{}{}",
243 color::Green,
244 color::Reset,
245 color::Yellow,
246 color::Bold,
247 req.relative_file_path(),
248 color::Reset,
249 );
250 Ok(())
251}
252
253fn write_gophermap<W>(w: &mut W, req: Request) -> Result<()>
255where
256 W: Write,
257{
258 let path = req.file_path();
259
260 let reader = if is_executable(&path) {
262 shell(&path, &[&req.query, &req.host, &req.port.to_string()])?
263 } else {
264 fs::read_to_string(&path)?
265 };
266
267 for line in reader.lines() {
268 write!(w, "{}", gph_line_to_gopher(line, &req))?;
269 }
270 info!(
271 "{}│{} Server reply:\t{}MAP {}{}{}",
272 color::Green,
273 color::Reset,
274 color::Yellow,
275 color::Bold,
276 req.relative_file_path(),
277 color::Reset,
278 );
279 Ok(())
280}
281
282fn gph_line_to_gopher(line: &str, req: &Request) -> String {
286 if line.starts_with('#') {
287 return "".to_string();
288 }
289
290 let mut line = line.trim_end_matches('\r').to_string();
291 if line.starts_with('[') && line.ends_with(']') && line.contains('|') {
292 let port = req.port.to_string();
294 line = line
295 .replacen('|', "", 1)
296 .trim_start_matches('[')
297 .trim_end_matches(']')
298 .replace("\\|", "__P_ESC_PIPE") .replace('|', "\t")
300 .replace("__P_ESC_PIPE", "\\|")
301 .replace("\tserver\t", format!("\t{}\t", req.host).as_ref())
302 .replace("\tport", format!("\t{}", port).as_ref());
303 let tabs = line.matches('\t').count();
304 if tabs < 1 {
305 line.push('\t');
306 line.push_str("(null)");
307 }
308 if tabs < 2 {
311 line.push('\t');
312 line.push_str(&req.host);
313 line.push('\t');
314 line.push_str(&port);
315 } else if tabs < 3 {
316 line.push('\t');
317 line.push_str("70");
318 }
319 } else {
320 match line.matches('\t').count() {
321 0 => {
322 line.insert(0, 'i');
324 line.push_str(&format!("\t(null)\t{}\t{}", req.host, req.port))
325 }
326 1 => line.push_str(&format!("\t{}\t{}", req.host, req.port)),
328 2 => line.push_str(&format!("\t{}", req.port)),
329 _ => {}
330 }
331 }
332 line.push_str("\r\n");
333 line
334}
335
336fn write_not_found<W>(w: &mut W, req: Request) -> Result<()>
337where
338 W: Write,
339{
340 let line = format!("3Not Found: {}\t/\tnone\t70\r\n", req.selector);
341 info!(
342 "{}│ Not found: {}{}{}",
343 color::Red,
344 color::Cyan,
345 req.relative_file_path(),
346 color::Reset,
347 );
348 write!(w, "{}", line)?;
349 Ok(())
350}
351
352fn file_type(dir: &fs::DirEntry) -> gopher::Type {
354 let metadata = match dir.metadata() {
355 Err(_) => return gopher::Type::Error,
356 Ok(md) => md,
357 };
358
359 if metadata.is_file() {
360 if let Ok(file) = fs::File::open(&dir.path()) {
361 let mut buffer: Vec<u8> = vec![];
362 let _ = file.take(MAX_PEEK_SIZE as u64).read_to_end(&mut buffer);
363 if content_inspector::inspect(&buffer).is_binary() {
364 gopher::Type::Binary
365 } else {
366 gopher::Type::Text
367 }
368 } else {
369 gopher::Type::Error
370 }
371 } else if metadata.is_dir() {
372 gopher::Type::Menu
373 } else {
374 gopher::Type::Error
375 }
376}
377
378fn fs_exists(path: &str) -> bool {
380 Path::new(path).exists()
381}
382
383fn is_executable(path: &str) -> bool {
385 if let Ok(meta) = fs::metadata(path) {
386 meta.permissions().mode() & 0o111 != 0
387 } else {
388 false
389 }
390}
391
392fn shell(path: &str, args: &[&str]) -> Result<String> {
394 let output = Command::new(path).args(args).output()?;
395 if output.status.success() {
396 Ok(str::from_utf8(&output.stdout)?.to_string())
397 } else {
398 Ok(str::from_utf8(&output.stderr)?.to_string())
399 }
400}
401
402fn sort_paths(dir_path: &str, reverse: bool) -> Result<Vec<DirEntry>> {
404 let mut paths: Vec<_> = fs::read_dir(dir_path)?.filter_map(|r| r.ok()).collect();
405 let is_dir = |entry: &fs::DirEntry| match entry.file_type() {
406 Ok(t) => t.is_dir(),
407 _ => false,
408 };
409 paths.sort_by(|a, b| {
410 let a_is_dir = is_dir(a);
411 let b_is_dir = is_dir(b);
412 if a_is_dir && b_is_dir || !a_is_dir && !b_is_dir {
413 let ord = alphanumeric_sort::compare_os_str::<&Path, &Path>(
414 a.path().as_ref(),
415 b.path().as_ref(),
416 );
417 if reverse {
418 ord.reverse()
419 } else {
420 ord
421 }
422 } else if is_dir(a) {
423 Ordering::Less
424 } else if is_dir(b) {
425 Ordering::Greater
426 } else {
427 Ordering::Equal }
429 });
430 Ok(paths)
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 macro_rules! str_path {
438 ($e:expr) => {
439 $e.path()
440 .to_str()
441 .unwrap()
442 .trim_start_matches("tests/sort/")
443 };
444 }
445
446 #[test]
447 fn test_sort_directory() {
448 let paths = sort_paths("tests/sort", false).unwrap();
449 assert_eq!(str_path!(paths[0]), "zzz");
450 assert_eq!(str_path!(paths[1]), "phetch-v0.1.7-linux-armv7.tar.gz");
451 assert_eq!(
452 str_path!(paths[paths.len() - 1]),
453 "phetch-v0.1.11-macos.zip"
454 );
455 }
456
457 #[test]
458 fn test_rsort_directory() {
459 let paths = sort_paths("tests/sort", true).unwrap();
460 assert_eq!(str_path!(paths[0]), "zzz");
461 assert_eq!(str_path!(paths[1]), "phetch-v0.1.11-macos.zip");
462 assert_eq!(
463 str_path!(paths[paths.len() - 1]),
464 "phetch-v0.1.7-linux-armv7.tar.gz"
465 );
466 }
467
468 #[test]
469 fn test_gph_line_to_gopher() {
470 let req = Request::from("localhost", 70, ".").unwrap();
471
472 assert_eq!(
473 gph_line_to_gopher("regular line test", &req),
474 "iregular line test (null) localhost 70\r\n"
475 );
476 assert_eq!(
477 gph_line_to_gopher("1link test /test localhost 70", &req),
478 "1link test /test localhost 70\r\n"
479 );
480
481 let line = "0short link test /test";
482 assert_eq!(
483 gph_line_to_gopher(line, &req),
484 "0short link test /test localhost 70\r\n"
485 );
486 }
487
488 #[test]
489 fn test_gph_geomyidae() {
490 let req = Request::from("localhost", 7070, ".").unwrap();
491
492 assert_eq!(
493 gph_line_to_gopher("[1|phkt.io|/|phkt.io]", &req),
494 "1phkt.io / phkt.io 70\r\n"
495 );
496 assert_eq!(gph_line_to_gopher("#[1|phkt.io|/|phkt.io]", &req), "");
497 assert_eq!(
498 gph_line_to_gopher("[1|sdf6000|/not-real|sdf.org|6000]", &req),
499 "1sdf6000 /not-real sdf.org 6000\r\n"
500 );
501 assert_eq!(
502 gph_line_to_gopher("[1|R-36|/]", &req),
503 "1R-36 / localhost 7070\r\n"
504 );
505 assert_eq!(
506 gph_line_to_gopher("[1|R-36|/|server|port]", &req),
507 "1R-36 / localhost 7070\r\n"
508 );
509 assert_eq!(
510 gph_line_to_gopher("[0|file - comment|/file.dat|server|port]", &req),
511 "0file - comment /file.dat localhost 7070\r\n"
512 );
513 assert_eq!(
514 gph_line_to_gopher(
515 "[0|some \\| escape and [ special characters ] test|error|server|port]",
516 &req
517 ),
518 "0some \\| escape and [ special characters ] test error localhost 7070\r\n"
519 );
520 assert_eq!(
521 gph_line_to_gopher("[|empty type||server|port]", &req),
522 "empty type\t\tlocalhost\t7070\r\n",
523 );
524 }
525}