/ src / main.rs
main.rs
  1  use std::fs;
  2  use std::io::{self, Read, Write};
  3  use std::net::{TcpListener, TcpStream};
  4  use std::path::{Path, PathBuf, Component};
  5  use std::thread;
  6  
  7  // Define the Gopher item types
  8  enum GopherItemType {
  9      TextFile,         // 0
 10      Directory,        // 1
 11      BinaryFile,       // 9
 12      GifImage,         // g
 13      Image,            // I
 14      Html,             // h
 15      InfoLine,         // i
 16      Error,            // 3
 17  }
 18  
 19  impl GopherItemType {
 20      fn to_char(&self) -> char {
 21          match self {
 22              GopherItemType::TextFile => '0',
 23              GopherItemType::Directory => '1',
 24              GopherItemType::BinaryFile => '9',
 25              GopherItemType::GifImage => 'g',
 26              GopherItemType::Image => 'I',
 27              GopherItemType::Html => 'h',
 28              GopherItemType::InfoLine => 'i',
 29              GopherItemType::Error => '3',
 30          }
 31      }
 32  }
 33  
 34  // Determine the Gopher item type based on file extension
 35  fn get_item_type(path: &Path) -> GopherItemType {
 36      if path.is_dir() {
 37          return GopherItemType::Directory;
 38      }
 39      
 40      // Get file extension if it exists
 41      match path.extension().and_then(|ext| ext.to_str()) {
 42          Some(ext) => {
 43              match ext.to_lowercase().as_str() {
 44                  "txt" | "md" => GopherItemType::TextFile,
 45                  "gif" => GopherItemType::GifImage,
 46                  "jpg" | "jpeg" | "png" | "bmp" | "webp" => GopherItemType::Image,
 47                  "html" | "htm" => GopherItemType::Html,
 48                  _ => GopherItemType::BinaryFile, // Default to binary for unknown extensions
 49              }
 50          },
 51          None => GopherItemType::TextFile, // Default to text file if no extension
 52      }
 53  }
 54  
 55  // Sanitize path to prevent directory traversal attacks
 56  fn sanitize_path(input: &str) -> PathBuf {
 57      let path = PathBuf::from(input);
 58      path.components()
 59          .filter(|component| {
 60              match component {
 61                  Component::ParentDir => false,  // Remove .. components
 62                  Component::Normal(_) => true,
 63                  Component::CurDir => false,     // Remove . components
 64                  _ => true,
 65              }
 66          })
 67          .collect()
 68  }
 69  
 70  fn main() -> io::Result<()> {
 71      // Create directory for our gopher files if it doesn't exist
 72      let content_dir = "gopher_content";
 73      if !Path::new(content_dir).exists() {
 74          fs::create_dir(content_dir)?;
 75      }
 76      
 77      // Create images directory if it doesn't exist
 78      let images_dir = format!("{}/images", content_dir);
 79      if !Path::new(&images_dir).exists() {
 80          fs::create_dir(&images_dir)?;
 81      }
 82  
 83      // Create sample files if they don't exist
 84      create_sample_files(content_dir)?;
 85  
 86      // Start server
 87      let listener = TcpListener::bind("0.0.0.0:7070")?;
 88      println!("Gopher server listening on port 7070");
 89      println!("Serving files from directory: {}", content_dir);
 90  
 91      for stream in listener.incoming() {
 92          match stream {
 93              Ok(stream) => {
 94                  let content_dir = content_dir.to_string();
 95                  thread::spawn(move || {
 96                      if let Err(e) = handle_client(stream, &content_dir) {
 97                          eprintln!("Error handling client: {}", e);
 98                      }
 99                  });
100              }
101              Err(e) => {
102                  eprintln!("Connection failed: {}", e);
103              }
104          }
105      }
106  
107      Ok(())
108  }
109  
110  fn create_sample_files(content_dir: &str) -> io::Result<()> {
111      // Create about.txt if it doesn't exist
112      let about_path = format!("{}/about.txt", content_dir);
113      if !Path::new(&about_path).exists() {
114          fs::write(
115              about_path,
116              "This is the about page for my gopher server.\n\nIt's a simple Rust implementation!"
117          )?;
118      }
119  
120      // Create welcome.txt if it doesn't exist
121      let welcome_path = format!("{}/welcome.txt", content_dir);
122      if !Path::new(&welcome_path).exists() {
123          fs::write(
124              welcome_path,
125              "Welcome to my Gopher server!\n\nThis server is written in Rust."
126          )?;
127      }
128  
129      Ok(())
130  }
131  
132  fn handle_client(mut stream: TcpStream, content_dir: &str) -> io::Result<()> {
133      let mut buffer = [0; 1024];
134      
135      // Add a timeout for read operations to prevent hanging connections
136      stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
137      
138      let bytes_read = match stream.read(&mut buffer) {
139          Ok(n) => n,
140          Err(e) => {
141              eprintln!("Error reading from stream: {}", e);
142              return Err(e);
143          }
144      };
145      
146      // Convert buffer to a string and trim trailing whitespace
147      let selector = if bytes_read > 0 {
148          match String::from_utf8(buffer[0..bytes_read].to_vec()) {
149              Ok(s) => s.trim_end_matches(&['\r', '\n'][..]).to_string(),
150              Err(_) => {
151                  // Invalid UTF-8 request
152                  eprintln!("Received invalid UTF-8 request");
153                  let error_msg = format!("{}Error: Invalid request\r\n.\r\n", GopherItemType::Error.to_char());
154                  stream.write_all(error_msg.as_bytes())?;
155                  return Ok(());
156              }
157          }
158      } else {
159          String::new()
160      };
161      
162      println!("Received selector: '{}'", selector);
163      
164      // Empty selector or just a newline means serve the root menu
165      if selector.is_empty() || selector == "\r\n" || selector == "\n" {
166          serve_menu(&mut stream, content_dir, "")?;
167      } else {
168          // Clean the selector by removing any leading '/'
169          let clean_selector = selector.trim_start_matches('/');
170          
171          // Sanitize path to prevent directory traversal
172          let safe_path = sanitize_path(clean_selector);
173          let path = format!("{}/{}", content_dir, safe_path.to_string_lossy());
174          
175          // Check if it's a directory request
176          if Path::new(&path).is_dir() {
177              serve_menu(&mut stream, content_dir, &safe_path.to_string_lossy())?;
178          } else {
179              serve_content(&mut stream, content_dir, &safe_path.to_string_lossy())?;
180          }
181      }
182  
183      Ok(())
184  }
185  
186  fn serve_menu(stream: &mut TcpStream, content_dir: &str, subdir: &str) -> io::Result<()> {
187      println!("Serving menu for directory: {}", if subdir.is_empty() { "root" } else { subdir });
188      
189      // Build the full path to the directory
190      let dir_path = if subdir.is_empty() {
191          PathBuf::from(content_dir)
192      } else {
193          // Sanitize subdir path
194          let safe_subdir = sanitize_path(subdir);
195          PathBuf::from(format!("{}/{}", content_dir, safe_subdir.to_string_lossy()))
196      };
197      
198      // Check if directory exists and is actually a directory
199      if !dir_path.exists() || !dir_path.is_dir() {
200          let error_msg = format!("{}Error: Directory not found\r\n.\r\n", GopherItemType::Error.to_char());
201          stream.write_all(error_msg.as_bytes())?;
202          return Ok(());
203      }
204      
205      // Start building the menu
206      let mut menu = String::from(format!("{}Welcome to my Gopher Server!\r\n", GopherItemType::InfoLine.to_char()));
207      menu.push_str(&format!("{}----------------------------\r\n", GopherItemType::InfoLine.to_char()));
208      
209      // If we're in a subdirectory, add a link to go back up
210      if !subdir.is_empty() {
211          // Get parent directory
212          let safe_subdir = sanitize_path(subdir);
213          let parent = safe_subdir.parent().map_or("", |p| p.to_str().unwrap_or(""));
214          
215          let selector = if parent.is_empty() { "/".to_string() } else { format!("/{}", parent) };
216          menu.push_str(&format!("{}Go Back to Parent Directory\t{}\t45.76.84.191\t7070\r\n", 
217              GopherItemType::Directory.to_char(), selector));
218      }
219      
220      // Read directory and add all entries to the menu
221      match fs::read_dir(&dir_path) {
222          Ok(entries) => {
223              let mut entries: Vec<_> = entries.filter_map(Result::ok).collect();
224              
225              // Sort entries - directories first, then files
226              entries.sort_by(|a, b| {
227                  let a_is_dir = a.path().is_dir();
228                  let b_is_dir = b.path().is_dir();
229                  
230                  if a_is_dir && !b_is_dir {
231                      std::cmp::Ordering::Less
232                  } else if !a_is_dir && b_is_dir {
233                      std::cmp::Ordering::Greater
234                  } else {
235                      // Both are the same type, sort by name
236                      a.file_name().cmp(&b.file_name())
237                  }
238              });
239              
240              for entry in entries {
241                  let path = entry.path();
242                  
243                  if let Some(filename) = path.file_name() {
244                      if let Some(filename_str) = filename.to_str() {
245                          // Skip hidden files (starting with .)
246                          if filename_str.starts_with('.') {
247                              continue;
248                          }
249                          
250                          // Determine the item type
251                          let item_type = get_item_type(&path);
252                          
253                          // Create the selector path
254                          let selector = if subdir.is_empty() {
255                              format!("/{}", filename_str)
256                          } else {
257                              format!("/{}/{}", subdir, filename_str)
258                          };
259                          
260                          // Add to menu with the appropriate item type
261                          menu.push_str(&format!(
262                              "{}{}\t{}\t45.76.84.191\t7070\r\n",
263                              item_type.to_char(),
264                              filename_str,
265                              selector
266                          ));
267                      }
268                  }
269              }
270          }
271          Err(e) => {
272              eprintln!("Error reading directory: {}", e);
273              menu.push_str(&format!("{}Error reading directory: {}\r\n", 
274                  GopherItemType::InfoLine.to_char(), e));
275          }
276      }
277      
278      // End of menu marker
279      menu.push_str(".\r\n");
280      
281      stream.write_all(menu.as_bytes())?;
282      Ok(())
283  }
284  
285  fn serve_content(stream: &mut TcpStream, content_dir: &str, selector: &str) -> io::Result<()> {
286      // Sanitize the selector to prevent path traversal
287      let safe_path = sanitize_path(selector);
288      let path = format!("{}/{}", content_dir, safe_path.to_string_lossy());
289      let path_buf = PathBuf::from(&path);
290      
291      println!("Requested file: '{}', serving from path: '{}'", selector, path);
292      
293      if path_buf.exists() && path_buf.is_file() {
294          // Determine if we're serving a text or binary file
295          let item_type = get_item_type(&path_buf);
296          
297          match item_type {
298              GopherItemType::TextFile | GopherItemType::Html => {
299                  // For text files, send the content as is
300                  match fs::read_to_string(&path) {
301                      Ok(content) => {
302                          println!("Serving text file: {}", path);
303                          stream.write_all(content.as_bytes())?;
304                      },
305                      Err(e) => {
306                          eprintln!("Error reading text file: {}", e);
307                          let error_msg = format!("{}Error reading file: {}\r\n", 
308                              GopherItemType::Error.to_char(), e);
309                          stream.write_all(error_msg.as_bytes())?;
310                      }
311                  }
312              },
313              // For binary files, GIFs and other images, send raw bytes
314              _ => {
315                  println!("Serving binary file: {}", path);
316                  match fs::read(&path) {
317                      Ok(content) => {
318                          stream.write_all(&content)?;
319                      },
320                      Err(e) => {
321                          eprintln!("Error reading binary file: {}", e);
322                          let error_msg = format!("{}Error reading file: {}\r\n", 
323                              GopherItemType::Error.to_char(), e);
324                          stream.write_all(error_msg.as_bytes())?;
325                      }
326                  }
327              }
328          }
329      } else {
330          println!("File not found or is a directory");
331          let error_msg = format!("{}Error: File '{}' not found or is a directory.\r\n", 
332              GopherItemType::Error.to_char(), selector);
333          stream.write_all(error_msg.as_bytes())?;
334      }
335      
336      Ok(())
337  }