phd/
server.rs

1//! A simple multi-threaded Gopher server.
2
3use 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
17/// phd tries to be light on resources, so we only allow a low number
18/// of simultaneous connections.
19const MAX_WORKERS: usize = 10;
20
21/// how many bytes of a file to read when trying to guess binary vs text?
22const MAX_PEEK_SIZE: usize = 1024;
23
24/// Files not displayed in directory listings.
25const IGNORED_FILES: [&str; 3] = ["header.gph", "footer.gph", ".reverse"];
26
27/// Whether to print info!() messages to stdout.
28/// Defaults to true.
29static SHOW_INFO: AtomicBool = AtomicBool::new(true);
30
31/// Hide info! messages.
32fn hide_info() {
33    SHOW_INFO.swap(false, AtomicOrdering::Relaxed);
34}
35
36/// Print status message to the server's stdout.
37macro_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
51/// Starts a Gopher server at the specified host, port, and root directory.
52pub 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
87/// Reads from the client and responds.
88fn 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
106/// Render a response to a String.
107pub 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
116/// Writes a response to a client based on a Request.
117fn write_response<W>(w: &mut W, mut req: Request) -> Result<()>
118where
119    W: Write,
120{
121    let path = req.file_path();
122
123    // check for dir.gph if we're looking for dir
124    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        // check for index.gph if we're looking for dir
132        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
156/// Send a directory listing (menu) to the client based on a Request.
157fn 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    // show directory entries
183    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
233/// Send a file to the client based on a Request.
234fn 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
253/// Send a gophermap (menu) to the client based on a Request.
254fn write_gophermap<W>(w: &mut W, req: Request) -> Result<()>
255where
256    W: Write,
257{
258    let path = req.file_path();
259
260    // Run the file and use its output as content if it's executable.
261    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
282/// Given a single line from a .gph file, convert it into a
283/// Gopher-format line. Supports a basic format where lines without \t
284/// get an `i` prefixed, and the geomyidae format.
285fn 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        // [1|name|sel|server|port]
293        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") // cheap hack
299            .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 a link is missing host + port, assume it's this server.
309        // if it's just missing the port, assume port 70
310        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                // Always insert `i` prefix to any lines without tabs.
323                line.insert(0, 'i');
324                line.push_str(&format!("\t(null)\t{}\t{}", req.host, req.port))
325            }
326            // Auto-add host and port to lines with just a selector.
327            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
352/// Determine the gopher type for a DirEntry on disk.
353fn 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
378/// Does the file exist? Y'know.
379fn fs_exists(path: &str) -> bool {
380    Path::new(path).exists()
381}
382
383/// Is the file at the given path executable?
384fn 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
392/// Run a script and return its output.
393fn 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
402/// Sort directory paths: dirs first, files 2nd, version #s respected.
403fn 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 // what
428        }
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}