diff --git a/src/main.rs b/src/main.rs index 79268d7..a3fa0af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,2530 +1,2572 @@ -#[macro_use] -extern crate rocket; - -mod archiver; -mod config; -mod nojs; -mod parser; -mod save; -mod template; - -use config::Config; -use std::sync::mpsc; -use std::thread; - -use chrono::{DateTime, Utc}; -use deunicode::deunicode; -use rand::{thread_rng, Rng}; -use rocket::{ - fairing::{Fairing, Info, Kind}, - http::{Header, Status}, - request::{FromRequest, Outcome}, - response::content, - Request, Response, State, -}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -use std::sync::{Arc, Mutex}; -use template::TemplateEngine; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Post { - id: String, - title: String, - author: String, - content: String, - raw_content: String, - created_at: DateTime, -} - -impl Post { - fn memory_size(&self) -> usize { - self.id.len() - + self.title.len() - + self.author.len() - + self.content.len() - + self.raw_content.len() - + 64 // Rough estimate for DateTime and struct overhead - } -} - -#[derive(Debug)] -struct CacheEntry { - post: Post, - last_accessed: DateTime, -} - -#[derive(Debug)] -struct PostCache { - entries: HashMap, - total_size: usize, - max_size: usize, // 128 MB = 128 * 1024 * 1024 -} - -impl PostCache { - fn new(max_size_mb: usize) -> Self { - PostCache { - entries: HashMap::new(), - total_size: 0, - max_size: max_size_mb * 1024 * 1024, - } - } - - // Add a non-cloning get for read-only access - fn get_ref(&mut self, post_id: &str) -> Option<&Post> { - if let Some(entry) = self.entries.get_mut(post_id) { - entry.last_accessed = Utc::now(); - Some(&entry.post) - } else { - None - } - } - - fn contains_key(&self, post_id: &str) -> bool { - self.entries.contains_key(post_id) - } - - fn insert(&mut self, post_id: String, post: Post) { - let post_size = post.memory_size(); - - // Remove existing entry if it exists - if let Some(old_entry) = self.entries.remove(&post_id) { - self.total_size -= old_entry.post.memory_size(); - println!("Cache UPDATE for post: {}", post_id); - } else { - println!("Cache INSERT for post: {}", post_id); - } - - // Add new entry size - self.total_size += post_size; - - // Evict oldest entries if over limit - let mut evicted_count = 0; - while self.total_size > self.max_size && !self.entries.is_empty() { - self.evict_oldest(); - evicted_count += 1; - } - - if evicted_count > 0 { - println!( - "Cache EVICTED {} old posts to stay under 128MB limit", - evicted_count - ); - } - - // Insert new entry - let entry = CacheEntry { - post, - last_accessed: Utc::now(), - }; - - self.entries.insert(post_id.clone(), entry); - let (size_val, size_unit) = match self.total_size { - b if b < 1_024 => (b as f64, "B"), - b if b < 1_024 * 1_024 => (b as f64 / 1_024.0, "KB"), - b if b < 1_024 * 1_024 * 1_024 => (b as f64 / (1_024.0 * 1_024.0), "MB"), - b => (b as f64 / (1_024.0 * 1_024.0 * 1_024.0), "GB"), - }; - println!( - "Cache now contains {} posts, total size: {:.2} {}", - self.entries.len(), - size_val, - size_unit - ); - } - - fn evict_oldest(&mut self) { - if let Some(oldest_id) = self.find_oldest_entry() { - if let Some(old_entry) = self.entries.remove(&oldest_id) { - self.total_size -= old_entry.post.memory_size(); - println!( - "Cache EVICT for post: {} (freed: {} KB)", - oldest_id, - old_entry.post.memory_size() / 1024 - ); - } - } - } - - fn find_oldest_entry(&self) -> Option { - self.entries - .iter() - .min_by_key(|(_, entry)| entry.last_accessed) - .map(|(id, _)| id.clone()) - } - - fn purge_deleted(&mut self) { - let stale: Vec = self - .entries - .keys() - .filter(|id| !std::path::Path::new(&format!("content/{}.md", id)).exists()) - .cloned() - .collect(); - - for id in stale { - if let Some(entry) = self.entries.remove(&id) { - self.total_size -= entry.post.memory_size(); - println!("Cache EVICT for post: {}.md", id); - } - } - } -} - -type PostStorage = Arc>; -type FileSaveQueue = Mutex>; - -#[get("/")] -fn index(config: &State) -> content::RawHtml { - let engine = TemplateEngine::new("templates"); - let mut context = HashMap::new(); - context.insert("error".to_string(), "".to_string()); - context.insert("success".to_string(), "".to_string()); - context.insert( - "title_max_length".to_string(), - config.limits.title_max_length.to_string(), - ); - context.insert( - "alias_max_length".to_string(), - config.limits.alias_max_length.to_string(), - ); - context.insert( - "content_max_length".to_string(), - config.limits.content_max_length.to_string(), - ); - - let csrf_token = if config.security.csrf_protection_enabled { - generate_csrf_token_with_timestamp() - } else { - String::new() - }; - context.insert("csrf_token".to_string(), csrf_token); - - match engine.render_with_defaults("home", &context) { - Ok(html) => content::RawHtml(html), - Err(e) => content::RawHtml(format!("Template error: {}", e)), - } -} - -#[derive(FromForm)] -struct NewPost { - title: String, - content: String, - alias: String, - csrf_token: String, -} - -struct OnionLocationFairing { - onion_url: String, -} - -#[rocket::async_trait] -impl Fairing for OnionLocationFairing { - fn info(&self) -> Info { - Info { - name: "Onion-Location header", - kind: Kind::Response, - } - } - - async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { - if !response.status().class().is_success() { - return; - } - let is_html = response - .content_type() - .map(|ct| ct.is_html()) - .unwrap_or(false); - if !is_html { - return; - } - - let host_is_onion = request - .host() - .map(|h| h.domain().as_str().ends_with(".onion")) - .unwrap_or(false); - let forwarded_https = request - .headers() - .get_one("X-Forwarded-Proto") - .map(|p| p.eq_ignore_ascii_case("https")) - .unwrap_or(false); - if !host_is_onion && !forwarded_https { - return; - } - - response.set_header(Header::new("Onion-Location", self.onion_url.clone())); - } -} - - -// Add security headers to every response. -struct SecurityHeadersFairing; - -#[rocket::async_trait] -impl Fairing for SecurityHeadersFairing { - fn info(&self) -> Info { - Info { - name: "Security headers (CSP et al.)", - kind: Kind::Response, - } - } - - async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { - // TODO: Refactor HTML and remove unsafe-inline. - response.set_header(Header::new( - "Content-Security-Policy", - "default-src 'self'; \ - base-uri 'self'; \ - form-action 'self'; \ - frame-ancestors 'self'; \ - img-src 'self' https: http:; \ - media-src 'self' https: http:; \ - object-src 'none'; \ - script-src 'self' 'unsafe-inline'; \ - script-src-attr 'none'; \ - style-src 'self' https: 'unsafe-inline'", - )); - response.set_header(Header::new("Cross-Origin-Opener-Policy", "same-origin")); - response.set_header(Header::new("Cross-Origin-Resource-Policy", "same-origin")); - response.set_header(Header::new("Origin-Agent-Cluster", "?1")); - response.set_header(Header::new("Referrer-Policy", "no-referrer")); - response.set_header(Header::new( - "Strict-Transport-Security", - "max-age=15552000; includeSubDomains", - )); - response.set_header(Header::new("X-Content-Type-Options", "nosniff")); - response.set_header(Header::new("X-DNS-Prefetch-Control", "off")); - response.set_header(Header::new("X-Download-Options", "noopen")); - response.set_header(Header::new("X-Frame-Options", "SAMEORIGIN")); - response.set_header(Header::new("X-Permitted-Cross-Domain-Policies", "none")); - response.set_header(Header::new("X-XSS-Protection", "0")); - response.set_header(Header::new("Cache-Control", "no-store, max-age=0")); - } -} - -struct CsrfProtected; - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for CsrfProtected { - type Error = (); - - async fn from_request(_request: &'r Request<'_>) -> Outcome { - Outcome::Success(CsrfProtected) - } -} - -fn generate_post_id(title: &str, storage: &PostStorage) -> Result { - let now = Utc::now(); - let date_str = now.format("%m-%d-%Y").to_string(); - - // Transliterate ALL characters to ASCII equivalents (safe for all input) - let transliterated_title = deunicode(title); - - // Create URL-safe slug from transliterated title - let title_slug: String = transliterated_title - .trim() - .to_lowercase() - .chars() - .filter_map(|c| { - if c.is_alphanumeric() { - Some(c) - } else if c.is_whitespace() || c == '-' || c == '_' { - Some('-') - } else { - None - } - }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - - // Apply character limit with truncation if needed - let max_slug_length = 250 - date_str.len() - 1; // Reserve space for "-{date}" - let final_slug = if title_slug.len() > max_slug_length { - let truncate_to = max_slug_length.saturating_sub(4); // Reserve space for "-etc" - if truncate_to > 0 { - // Find the last complete word that fits - let truncated = &title_slug[..truncate_to]; - let last_dash = truncated.rfind('-').unwrap_or(truncated.len()); - format!("{}-etc", &title_slug[..last_dash]) - } else { - "etc".to_string() - } - } else { - title_slug - }; - - if final_slug.is_empty() { - // Use "na-" + 4 random characters only for completely empty titles - let mut rng = thread_rng(); - let chars: String = (0..4) - .map(|_| { - let chars = b"abcdefghijklmnopqrstuvwxyz0123456789"; - chars[rng.gen_range(0..chars.len())] as char - }) - .collect(); - - let fallback_slug = format!("na-{}", chars); - let posts = storage.lock().unwrap(); - - for i in 0..1000 { - let post_id = if i == 0 { - format!("{}-{}", fallback_slug, date_str) - } else { - format!("{}-{}-{}", fallback_slug, date_str, i) - }; - - if !posts.contains_key(&post_id) { - return Ok(post_id); - } - } - - return Err( - "All slots for this title and date are taken. Please choose another title.".to_string(), - ); - } - - let posts = storage.lock().unwrap(); - - // Try to find an available slot (0-999) - for i in 0..1000 { - let post_id = if i == 0 { - format!("{}-{}", final_slug, date_str) - } else { - format!("{}-{}-{}", final_slug, date_str, i) - }; - - if !posts.contains_key(&post_id) { - return Ok(post_id); - } - } - - Err("All slots for this title and date are taken. Please choose another title.".to_string()) -} - -fn generate_csrf_token() -> String { - use rand::Rng; - let mut rng = rand::thread_rng(); - (0..32) - .map(|_| format!("{:02x}", rng.gen::())) - .collect::() -} - -fn generate_csrf_token_with_timestamp() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let random_part = generate_csrf_token(); - let combined = format!("{}:{}", timestamp, random_part); - - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - combined.hash(&mut hasher); - let hash = hasher.finish(); - - format!("{}.{:x}", combined, hash) -} - -fn is_valid_csrf_token(token: &str) -> bool { - if token.is_empty() { - return false; - } - - // Split token into data and hash parts - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() != 2 { - return false; - } - - let data = parts[0]; - let provided_hash = parts[1]; - - // Recreate hash from data - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - data.hash(&mut hasher); - let expected_hash = format!("{:x}", hasher.finish()); - - // Verify hash matches - if provided_hash != expected_hash { - return false; - } - - // Check timestamp (token expires after 1 hour) - let data_parts: Vec<&str> = data.split(':').collect(); - if data_parts.len() != 2 { - return false; - } - - if let Ok(timestamp) = data_parts[0].parse::() { - use std::time::{SystemTime, UNIX_EPOCH}; - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Token is valid for 24 hours - current_time - timestamp < 86400 - } else { - false - } -} - -#[post("/create", data = "
")] -fn create_post( - _csrf: CsrfProtected, - form: rocket::form::Form, - storage: &State, - file_queue: &State, - config: &State, -) -> Result> { - if config.security.csrf_protection_enabled { - if !is_valid_csrf_token(&form.csrf_token) { - let error_url = format!("/?error=csrf_token_invalid"); - return Ok(rocket::response::Redirect::to(error_url)); - } - } - - let alias = if form.alias.trim().is_empty() { - None - } else { - Some(form.alias.as_str()) - }; - if let Err(error) = config.validate_post(&form.title, &form.content, alias) { - let error_url = format!("/?error={}", error); - return Ok(rocket::response::Redirect::to(error_url)); - } - - let post_id = match generate_post_id(&form.title, storage) { - Ok(id) => id, - Err(_) => return Ok(rocket::response::Redirect::to("/?error=no_available_slots")), - }; - - let rendered_content = parser::render_markdown_with_config(&form.content, &config); - - let post = Post { - id: post_id.clone(), - title: parser::sanitize_text(&form.title), - author: parser::sanitize_text(&form.alias), - content: rendered_content, - raw_content: form.content.clone(), - created_at: Utc::now(), - }; - - let post_for_file = post.clone(); - { - let mut posts = storage.lock().unwrap(); - posts.insert(post_id.clone(), post); // Move post here - } - - if let Ok(tx) = file_queue.lock() { - if let Err(_) = tx.send(post_for_file) { - eprintln!("Failed to queue post for background save: {}", post_id); - } - } - - Ok(rocket::response::Redirect::to(format!("/{}", post_id))) -} - -fn parse_yaml_frontmatter(file_content: &str) -> Option<(String, String, DateTime, String)> { - let after_open = file_content.strip_prefix("---\n")?; - - let closing_pos = after_open.find("\n---\n")?; - let frontmatter_block = &after_open[..closing_pos]; - let after_closing = &after_open[(closing_pos + 5)..]; // skip "\n---\n" - let raw_content = after_closing.strip_prefix('\n').unwrap_or(after_closing); - - let mut title = String::from("Untitled"); - let mut author = String::new(); - let mut date_str = String::new(); - - for line in frontmatter_block.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if let Some(value) = line.strip_prefix("title:") { - title = parser::sanitize_text(value.trim()); - } else if let Some(value) = line.strip_prefix("date:") { - date_str = value.trim().to_string(); - } else if let Some(value) = line.strip_prefix("author:") { - author = parser::sanitize_text(value.trim()); - } - } - - let created_at = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d") - .ok() - .and_then(|date| date.and_hms_opt(0, 0, 0)) - .map(|datetime| DateTime::::from_naive_utc_and_offset(datetime, Utc)) - .unwrap_or_else(|| Utc::now()); - - Some((title, author, created_at, raw_content.to_string())) -} - -fn parse_legacy_frontmatter(file_content: &str) -> Option<(String, String, DateTime, String)> { - let lines: Vec<&str> = file_content.splitn(4, '\n').collect(); - if lines.len() < 4 { - return None; - } - - let (date_str, author) = if let Some(pipe_pos) = lines[0].find(" | ") { - ( - lines[0][..pipe_pos].to_string(), - parser::sanitize_text(&lines[0][(pipe_pos + 3)..]), - ) - } else { - (lines[0].to_string(), "".to_string()) - }; - - let created_at = chrono::NaiveDate::parse_from_str(&date_str, "%B %d, %Y") - .ok() - .and_then(|date| date.and_hms_opt(0, 0, 0)) - .map(|datetime| DateTime::::from_naive_utc_and_offset(datetime, Utc)) - .unwrap_or_else(|| Utc::now()); - - let title = parser::sanitize_text(lines[2].strip_prefix("# ").unwrap_or("Untitled")); - let raw_content = lines[3].to_string(); - - Some((title, author, created_at, raw_content)) -} - -#[get("/")] -fn view_post( - post_id: &str, - storage: &State, - config: &State, -) -> Result< - rocket::Either, content::RawText>, - ( - Status, - rocket::Either, content::RawHtml>, - ), -> { - let is_raw_request = post_id.ends_with(".md"); - let actual_post_id = if is_raw_request { - post_id.strip_suffix(".md").unwrap() - } else { - &post_id - }; - - if is_raw_request { - let file_path = format!("content/{}.md", actual_post_id); - return match std::fs::read_to_string(&file_path) { - Ok(raw_bytes) => Ok(rocket::Either::Right(content::RawText(raw_bytes))), - Err(_) => Err(( - Status::NotFound, - rocket::Either::Left(content::RawText("Page not found".to_string())), - )), - }; - } - - // Try to load from memory first with minimal lock time - let post_from_memory = { - let mut posts = storage.lock().unwrap(); - // Use the non-cloning get_ref for better performance - if let Some(post_ref) = posts.get_ref(actual_post_id) { - Some(post_ref.clone()) // Only clone when we actually found it - } else { - None - } - }; - - let post = match post_from_memory { - Some(post) => Some(post), - None => { - if save::post_file_exists(actual_post_id) { - if let Ok(file_content) = - std::fs::read_to_string(format!("content/{}.md", actual_post_id)) - { - let parsed = if file_content.starts_with("---\n") { - parse_yaml_frontmatter(&file_content) - } else { - parse_legacy_frontmatter(&file_content) - }; - - if let Some((title, author, created_at, raw_content)) = parsed { - let new_post = Post { - id: actual_post_id.to_string(), - title, - author, - content: parser::render_markdown_with_config(&raw_content, &config), - raw_content, - created_at, - }; - - { - let mut posts_write = storage.lock().unwrap(); - posts_write.insert(actual_post_id.to_string(), new_post.clone()); - } - - Some(new_post) - } else { - None - } - } else { - None - } - } else { - None - } - } - }; - - match post { - Some(post) => { - let engine = TemplateEngine::new("templates"); - let mut context = HashMap::new(); - - let rendered_content = post.content.clone(); - - context.insert("title".to_string(), post.title.clone()); - context.insert("content".to_string(), rendered_content); - context.insert("raw_content".to_string(), post.raw_content.clone()); - let author = if post.author.is_empty() { - "Anonymous".to_string() - } else { - post.author.clone() - }; - context.insert("author".to_string(), author); - - let author_display = if post.author.is_empty() { - "Anonymous · ".to_string() - } else { - format!("{} · ", post.author) - }; - context.insert("author_display".to_string(), author_display); - - context.insert( - "created_at".to_string(), - post.created_at.format("%B %d, %Y").to_string(), - ); - context.insert( - "created_at_iso".to_string(), - post.created_at - .format("%Y-%m-%dT00:00:00+00:00") - .to_string(), - ); - context.insert("post_id".to_string(), actual_post_id.to_string()); - - // OpenGraph variables - context.insert("url".to_string(), format!("/{}", actual_post_id)); - - let description = if post.raw_content.chars().count() > 160 { - let truncated: String = post.raw_content.chars().take(160).collect(); - format!("{}...", parser::html_attr_escape(&truncated)) - } else { - post.raw_content.clone() - }; - context.insert("description".to_string(), description); - - match engine.render("post", &context) { - Ok(html) => Ok(rocket::Either::Left(content::RawHtml(html))), - Err(e) => Ok(rocket::Either::Left(content::RawHtml(format!( - "Template error: {}", - e - )))), - } - } - None => Err(( - Status::NotFound, - rocket::Either::Right(content::RawHtml(NOT_FOUND_HTML.to_string())), - )), - } -} - -#[get("/markup")] -fn markup_page( - config: &State, -) -> Result, (Status, content::RawHtml)> { - serve_static_page("markup", config) -} - -#[get("/legal")] -fn legal_page( - config: &State, -) -> Result, (Status, content::RawHtml)> { - serve_static_page("legal", config) -} - -#[get("/about")] -fn about_page( - config: &State, -) -> Result, (Status, content::RawHtml)> { - serve_static_page("about", config) -} - -#[get("/api")] -fn api_page( - config: &State, -) -> Result, (Status, content::RawHtml)> { - serve_static_page("api", config) -} - -#[get("/robots.txt")] -fn robots_txt() -> content::RawText<&'static str> { - content::RawText( - "User-agent: *\n\ - Disallow: /\n\ - \n\ - # Allow specific paths\n\ - Allow: /api\n\ - Allow: /legal\n\ - Allow: /about\n\ - Allow: /markup\n", - ) -} - -#[get("/nojs")] -fn nojs_index(config: &State) -> content::RawHtml { - let html = index(config).0; - let clean_html = nojs::strip_javascript(&html); - // Update form action to point to /nojs/create - let nojs_html = clean_html.replace(r#"action="/create""#, r#"action="/nojs/create""#); - content::RawHtml(nojs_html) -} - -#[get("/nojs/")] -fn nojs_view_post( - post_id: &str, - storage: &State, - config: &State, -) -> Result< - rocket::Either, content::RawText>, - ( - Status, - rocket::Either, content::RawHtml>, - ), -> { - match view_post(post_id, storage, config) { - Ok(rocket::Either::Left(content::RawHtml(html))) => { - let clean_html = nojs::strip_javascript(&html); - let fixed_html = clean_html - .replace( - &format!(r#"href="/nojs/{}"#, post_id), - &format!(r#"href="/{}"#, post_id), - ) - .replace(r#"target="_blank">nojs"#, r#"target="_blank">js"#); - Ok(rocket::Either::Left(content::RawHtml(fixed_html))) - } - Ok(rocket::Either::Right(raw_text)) => Ok(rocket::Either::Right(raw_text)), - Err(error) => Err(error), - } -} - -#[post("/nojs/create", data = "")] -fn nojs_create_post( - _csrf: CsrfProtected, - form: rocket::form::Form, - storage: &State, - file_queue: &State, - config: &State, -) -> Result> { - if config.security.csrf_protection_enabled { - if !is_valid_csrf_token(&form.csrf_token) { - let error_url = format!("/nojs/?error=csrf_token_invalid"); - return Ok(rocket::response::Redirect::to(error_url)); - } - } - - let alias = if form.alias.trim().is_empty() { - None - } else { - Some(form.alias.as_str()) - }; - if let Err(error) = config.validate_post(&form.title, &form.content, alias) { - let error_url = format!("/nojs/?error={}", error); - return Ok(rocket::response::Redirect::to(error_url)); - } - - let post_id = match generate_post_id(&form.title, storage) { - Ok(id) => id, - Err(_) => { - return Ok(rocket::response::Redirect::to( - "/nojs/?error=no_available_slots", - )) - } - }; - - let rendered_content = parser::render_markdown_with_config(&form.content, &config); - - let post = Post { - id: post_id.clone(), - title: parser::sanitize_text(&form.title), - author: parser::sanitize_text(&form.alias), - content: rendered_content, - raw_content: form.content.clone(), - created_at: Utc::now(), - }; - - let post_for_file = post.clone(); - { - let mut posts = storage.lock().unwrap(); - posts.insert(post_id.clone(), post); // Move post here - } - - if let Ok(tx) = file_queue.lock() { - if let Err(_) = tx.send(post_for_file) { - eprintln!("Failed to queue post for background save: {}", post_id); - } - } - - Ok(rocket::response::Redirect::to(format!("/nojs/{}", post_id))) -} - -const NOT_FOUND_HTML: &str = r#" - - - 404 - Page not found - - - - - -

Page Not Found

-

Write Your Own

- -"#; - -fn serve_static_page( - page_name: &str, - config: &State, -) -> Result, (Status, content::RawHtml)> { - let file_path = format!("content/{}.md", page_name); - - match std::fs::read_to_string(&file_path) { - Ok(file_content) => { - let parsed = if file_content.starts_with("---\n") { - parse_yaml_frontmatter(&file_content) - } else { - parse_legacy_frontmatter(&file_content) - }; - - if let Some((title, author, created_at, raw_content)) = parsed { - let rendered_content = parser::render_markdown_with_config(&raw_content, &config); - - let engine = TemplateEngine::new("templates"); - let mut context = HashMap::new(); - context.insert("title".to_string(), title); - context.insert("content".to_string(), rendered_content); - context.insert( - "created_at".to_string(), - created_at.format("%B %d, %Y").to_string(), - ); - context.insert("author".to_string(), author); - context.insert("author_display".to_string(), String::new()); - context.insert( - "created_at_iso".to_string(), - created_at.format("%Y-%m-%dT00:00:00+00:00").to_string(), - ); - context.insert("url".to_string(), format!("/{}", page_name)); - context.insert("description".to_string(), String::new()); - context.insert("post_id".to_string(), page_name.to_string()); - - match engine.render("post", &context) { - Ok(html) => Ok(content::RawHtml(html)), - Err(e) => Ok(content::RawHtml(format!("Template error: {}", e))), - } - } else { - Ok(content::RawHtml(format!( - "

Error

Invalid file format for {}

", - page_name - ))) - } - } - Err(_) => Err(( - Status::NotFound, - content::RawHtml(NOT_FOUND_HTML.to_string()), - )), - } -} - -fn start_cache_purge_worker(storage: PostStorage, interval_mins: u64) { - thread::spawn(move || loop { - thread::sleep(std::time::Duration::from_secs(interval_mins * 60)); - let mut cache = storage.lock().unwrap(); - cache.purge_deleted(); - }); -} - -fn start_file_save_worker() -> mpsc::Sender { - let (tx, rx) = mpsc::channel::(); - - thread::spawn(move || { - for post in rx { - if let Err(e) = save::save_post_to_file(&post) { - eprintln!("Background file save failed for post {}: {}", post.id, e); - } - } - }); - - tx -} - -#[rocket::main] -async fn main() -> Result<(), rocket::Error> { - let args: Vec = std::env::args().collect(); - - if args.len() > 1 && args[1] == "archive" { - if args.len() < 3 { - eprintln!("Usage: cargo run archive "); - std::process::exit(1); - } - - let url = &args[2]; - let archiver = archiver::TelegraphArchiver::new(); - - match archiver.archive_url(url).await { - Ok(nonograph_url) => { - println!("Successfully archived Telegraph page!"); - println!("View at: http://localhost:8009{}", nonograph_url); - } - Err(e) => { - eprintln!("Error archiving page: {}", e); - std::process::exit(1); - } - } - - return Ok(()); - } - - // Default behavior - launch web server - let _rocket = rocket().launch().await?; - Ok(()) -} - -fn rocket() -> rocket::Rocket { - use rocket::data::{Limits, ToByteUnit}; - - let config = Config::load_with_logging(); - - let limits = Limits::default() - .limit("form", config.form_data_limit_bytes().bytes()) - .limit("data-form", config.form_data_limit_bytes().bytes()) - .limit("string", config.form_data_limit_bytes().bytes()); - - let storage = Arc::new(Mutex::new(PostCache::new(config.cache.max_cache_size_mb))); - start_cache_purge_worker(Arc::clone(&storage), config.cache.cache_purge_interval_mins); - let file_save_sender = start_file_save_worker(); - - let onion_url = config.resolve_onion_url(); - match &onion_url { - Some(url) => println!("Onion-Location advertising enabled: {}", url), - None => println!("Onion-Location disabled (no onion URL configured or detected)"), - } - - let mut rocket = rocket::build() - .configure(rocket::Config { - limits, - port: config.server.port, - address: config - .server - .address - .parse() - .unwrap_or("127.0.0.1".parse().unwrap()), - ..rocket::Config::default() - }) - .attach(SecurityHeadersFairing) - .manage(storage) - .manage(FileSaveQueue::new(file_save_sender)) - .manage(config) - .mount( - "/", - routes![ - index, - create_post, - view_post, - markup_page, - legal_page, - about_page, - api_page, - robots_txt, - nojs_index, - nojs_view_post, - nojs_create_post - ], - ); - - if let Some(url) = onion_url { - rocket = rocket.attach(OnionLocationFairing { onion_url: url }); - } - - rocket -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_post_id_generation() { - let storage = Arc::new(Mutex::new(PostCache::new(128))); - - let id1 = generate_post_id("Hello World", &storage).unwrap(); - assert!(id1.contains("hello-world")); - assert!(id1.contains(&Utc::now().format("%m-%d-%Y").to_string())); - - // Test with special characters - let id2 = generate_post_id("Hello, World! & More", &storage).unwrap(); - assert!(id2.contains("hello-world-more")); - } - - #[test] - fn test_markdown_rendering_basic() { - let input = "This is *bold* text and **italic** text."; - let output = parser::render_markdown(input); - // Basic test - the actual implementation needs proper regex - assert!(output.contains("bold")); - assert!(output.contains("italic")); - } - - #[test] - fn test_content_length_validation() { - let short_content = "a".repeat(100); - let long_content = "a".repeat(35000); - - assert!(short_content.len() <= 32000); - assert!(long_content.len() > 32000); - } - - #[test] - fn test_template_engine_basic() { - use std::fs; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); - let template_content = "

{{title}}

{{content}}

"; - fs::write(dir.path().join("test.html"), template_content).unwrap(); - - let engine = TemplateEngine::new(dir.path().to_str().unwrap()); - let mut context = HashMap::new(); - context.insert("title".to_string(), "Test Title".to_string()); - context.insert("content".to_string(), "Test content".to_string()); - - let result = engine.render("test", &context).unwrap(); - assert_eq!(result, "

Test Title

Test content

"); - } - - #[test] - fn test_slug_generation() { - let tests = vec![ - ("Hello World", "hello-world"), - ("Test-Post_123", "test-post-123"), - ("Special!@#$%Characters", "specialcharacters"), - (" Whitespace ", "whitespace"), - ("Multiple---Dashes", "multiple-dashes"), - ]; - - for (input, expected) in tests { - let slug: String = input - .trim() - .to_lowercase() - .chars() - .filter_map(|c| { - if c.is_ascii_alphanumeric() { - Some(c) - } else if c.is_whitespace() || c == '-' || c == '_' { - Some('-') - } else { - None - } - }) - .collect::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - - assert_eq!(slug, expected); - } - } - - #[test] - fn test_markdown_bold_formatting() { - let input = "This is **bold** text and more **bold text**."; - let output = parser::render_markdown(input); - assert!(output.contains("bold")); - assert!(output.contains("bold text")); - } - - #[test] - fn test_markdown_code_formatting() { - let input = "Here is `inline code` and more `code`."; - let output = parser::render_markdown(input); - // Note: Our current simple implementation doesn't handle this yet - // This test documents expected behavior - assert!(output.contains("inline code")); - } - - #[test] - fn test_content_sanitization() { - let malicious_content = ""; - let sanitized = ammonia::clean(malicious_content); - assert!(!sanitized.contains("Safe Title"; - let sanitized_title = parser::sanitize_text(&malicious_title); - assert_eq!(sanitized_title, "Safe Title"); - - let malicious_author = "BoldJohn Doe"; - let sanitized_author = parser::sanitize_text(&malicious_author); - assert_eq!(sanitized_author, "BoldJohn Doe"); - - let clean_text = "Normal Title"; - let sanitized_clean = parser::sanitize_text(&clean_text); - assert_eq!(sanitized_clean, "Normal Title"); - - let various_tags = "

Title

Content

"; - let sanitized_various = parser::sanitize_text(&various_tags); - assert_eq!(sanitized_various, "TitleContent"); - } - - #[test] - fn test_xss_attack_vectors() { - let xss_test_cases = [ - "", - "", - "", - "", - "", - "", - "", - "", - "<", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "