use std::{ collections::{HashMap, hash_map::Entry}, env, fs::File, io::{BufReader, Error, ErrorKind, Result, prelude::*}, net::{TcpListener, TcpStream}, path::{Path, PathBuf}, process::ExitCode, sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, }, thread, time::Duration, }; use ::chara::CharacterDefinition; use data_stream::{ FromStream, ToStream, collections::SizeSettings, default_settings::PortableSettings, from_stream, to_stream, }; use header_config::parse_config; use indexmap::IndexMap; use maud::html; use percent_encoding::percent_decode_str; use pukram2html::{Settings, convert, convert_extended, convert_subheader}; use threadpool::ThreadPool; mod chara; mod dialog; mod request; mod vn; use chara::render_character; use request::Request; use vn::render_novel; #[derive(Clone)] struct Comment { name: Box, text: Box, } #[derive(Default)] struct SiteInfo { comments: Mutex>, visits: AtomicUsize, up: AtomicUsize, down: AtomicUsize, } #[derive(Default)] struct Context { infos: HashMap, Arc>, } #[derive(Copy, Clone)] struct DocumentPaths<'a> { pk: &'a Path, mld: &'a Path, pki: Option<&'a Path>, audio: Option<&'a Path>, data: &'a Path, } fn main() -> ExitCode { let Ok(path) = env::current_dir() else { eprintln!("Current directory does not exist!"); return ExitCode::FAILURE; }; let mut args = env::args(); args.next(); let address = args.next(); let address = address.as_deref().unwrap_or("127.0.0.1:8080"); let password = args.next(); let partial_password = args.next(); start_server(path, address, password, partial_password); ExitCode::FAILURE } fn get_thread_pool_size() -> usize { std::env::var("THREAD_POOL") .map(|s| s.parse().unwrap_or(4)) .unwrap_or(4) } fn start_server( path: PathBuf, address: &str, password: Option, partial_password: Option, ) { let Ok(listener) = TcpListener::bind(address) else { eprintln!("Invalid bind address {address:?}!"); return; }; eprintln!("Started server on {address}"); let context = Arc::new(Mutex::new(Context::default())); let mut pool = ThreadPool::new(get_thread_pool_size()); for stream in listener.incoming() { eprintln!("New connection!"); let Ok(stream) = stream else { eprintln!("Connection failed!"); continue; }; if pool.active_count() == pool.max_count() { thread::sleep(Duration::from_secs(1)); if pool.active_count() == pool.max_count() { pool = ThreadPool::new(pool.max_count()) } } let context = context.clone(); let path = path.clone(); let password = password.clone(); let hidden_password = partial_password.clone(); pool.execute(move || { handle_connection( context, path, stream, password.as_deref(), hidden_password.as_deref(), ) }); } } fn fail(mut stream: TcpStream) { let _ = write!(stream, "HTTP/1.1 404 Not Found\r\n\r\n"); let _ = writeln!(stream, "Page not found!"); } impl ToStream for SiteInfo { fn to_stream(&self, stream: &mut W) -> Result<()> { let Ok(comments) = self.comments.lock() else { return Err(std::io::ErrorKind::ResourceBusy.into()); }; S::size_to_stream(comments.len(), stream)?; for Comment { name, text } in comments.iter() { let name_bytes = name.as_bytes(); S::size_to_stream(name_bytes.len(), stream)?; stream.write_all(name_bytes)?; let text_bytes = text.as_bytes(); S::size_to_stream(text_bytes.len(), stream)?; stream.write_all(text_bytes)?; } S::size_to_stream(self.visits.load(Ordering::Acquire), stream)?; S::size_to_stream(self.up.load(Ordering::Acquire), stream)?; S::size_to_stream(self.down.load(Ordering::Acquire), stream)?; Ok(()) } } impl FromStream for SiteInfo { fn from_stream(stream: &mut R) -> Result { let size = S::size_from_stream(stream)?; let comments = Mutex::new( (0..size) .map(|_| { let name_bytes = as FromStream>::from_stream(stream)?; let name = std::str::from_utf8(&name_bytes) .map_err(|e| Error::new(ErrorKind::InvalidData, e))? .into(); let text_bytes = as FromStream>::from_stream(stream)?; let text = std::str::from_utf8(&text_bytes) .map_err(|e| Error::new(ErrorKind::InvalidData, e))? .into(); Ok(Comment { name, text }) }) .collect::>>()?, ); let visits = S::size_from_stream(stream)?.into(); let up = S::size_from_stream(stream)?.into(); let down = S::size_from_stream(stream)?.into(); Ok(Self { comments, visits, up, down, }) } } fn handle_connection( context: Arc>, path: PathBuf, mut stream: TcpStream, password: Option<&str>, partial_password: Option<&str>, ) { let Some(request) = Request::from(&stream) else { eprintln!("Invalid request!"); return; }; eprintln!(); eprintln!("Request:"); eprintln!("- Method: {}", request.method); eprintln!("- Path: {}", request.path); eprintln!("- Version: {}", request.version); eprintln!("- Headers:"); for header in &request.headers { eprintln!(" - {header}"); } eprintln!("- Body: {}", request.body); eprintln!(); #[derive(PartialEq, Eq)] enum Access { None, Partial, Full, } let mut access = Access::None; let mut cookie = None; if let Some(password) = password { for entry in request.body.split('&') { let Some((key, input)) = entry.split_once('=') else { continue; }; if key == "password" { if input == password { access = Access::Full; cookie = Some(password); } else if Some(input) == partial_password { access = Access::Partial; cookie = partial_password; } break; } } if access == Access::None { for header in request.headers { let Some((key, values)) = header.split_once(':') else { continue; }; let key = key.trim(); if key.to_lowercase() != "cookie" { continue; } for cookie in values.split(',') { let cookie = cookie.trim(); let Some((name, state)) = cookie.split_once('=') else { continue; }; if name != "password" { continue; } if state == password { access = Access::Full; break; } else if Some(state) == partial_password { access = Access::Partial; break; } } } } if access == Access::None { let _ = write!(stream, "HTTP/1.1 200 OK\r\n"); let _ = write!(stream, "Content-Type: text/html; charset=\"utf-8\"\r\n"); let _ = write!(stream, "\r\n"); let html = html! { h1 { "Authentification required" } form method="POST" { input type="password" name="password" placeholder="Password"; br; input type="submit" value="Authenticate!"; } }; let _ = stream.write_all(html.into_string().as_bytes()); return; } } else { access = Access::Full; } let (mut relative_path, _) = request .path .split_once('?') .unwrap_or_else(|| (&request.path, Default::default())); if relative_path.contains("//") { fail(stream); return; } if let Some(path) = relative_path.strip_prefix('/') { relative_path = path; } let mut pk_path = path.clone(); let mut mld_path = path.clone(); let mut data_path = path.clone(); let (pki_path, audio_path, relative_path) = if relative_path.is_empty() { if access == Access::Full { pk_path.push("index.pk"); data_path.push("index.dat"); } else { pk_path.push("partial.pk"); data_path.push("partial.dat"); } (None, None, String::new()) } else { let mut pki_path = path.clone(); let mut audio_path = path.clone(); let path = percent_decode_str(relative_path).decode_utf8_lossy(); if let Some((path, mut file)) = path.rsplit_once('/') { println!("{path} -> {file}"); if path.contains('_') { let path = path.replace('_', " "); let replaced_file; if !file.contains('.') { replaced_file = file.replace('_', " "); file = &replaced_file; } let _ = write!(stream, "HTTP/1.1 308 Permanent Redirect\r\n"); let _ = write!(stream, "Location: /{path}/{file}\r\n\r\n"); return; } } pk_path.push(format!("{path}.pk")); mld_path.push(format!("{path}.mld")); if access == Access::Full { pki_path.push(format!("{path}.pki")); } else { pki_path.push(format!("{path}.pkc")); } audio_path.push(format!("{path}.mp3")); data_path.push(format!("{path}.dat")); (Some(pki_path), Some(audio_path), path.to_string()) }; let file_paths = DocumentPaths { pk: &pk_path, mld: &mld_path, pki: pki_path.as_ref().map(PathBuf::as_ref), audio: audio_path.as_ref().map(PathBuf::as_ref), data: &data_path, }; if let Some((_, ending)) = relative_path.rsplit_once('.') { let ending = ending.to_lowercase(); match ending.as_ref() { "jpg" => reply_binary(stream, &relative_path, "image", "jpeg", path), "png" | "tiff" | "gif" | "jpeg" => { reply_binary(stream, &relative_path, "image", &ending, path) } "mp3" | "wav" => reply_binary(stream, &relative_path, "audio", &ending, path), "chara" => reply_chara(stream, &relative_path, path), _ => fail(stream), } return; } if !Path::is_file(&pk_path) { fail(stream); return; } if let Some(pki_path) = &pki_path { if !Path::is_file(pki_path) { fail(stream); return; } } let info = if let Ok(mut context) = context.lock() { use Entry::*; match context.infos.entry(relative_path.clone().into_boxed_str()) { Occupied(o) => o.get().clone(), Vacant(v) => v .insert(Arc::new( File::open(&data_path) .map(|mut file| { from_stream::(&mut file).unwrap_or_default() }) .unwrap_or_default(), )) .clone(), } } else { return; }; handle_relative_connection( info, stream, &request.body, &relative_path, &path, file_paths, cookie, access != Access::Full, ) } fn reply_binary( mut stream: TcpStream, relative_path: &str, data_type: &str, file_type: &str, mut path: PathBuf, ) { path.push(relative_path); let Ok(mut file) = File::open(path) else { fail(stream); return; }; let _ = write!(stream, "HTTP/1.1 200 OK\r\n"); let _ = write!(stream, "Content-Type: {data_type}/{file_type}\r\n"); let _ = write!(stream, "\r\n"); let mut buf = [0; 0x400]; while let Ok(len) = file.read(&mut buf) { if len == 0 { break; } let _ = stream.write_all(&buf[0..len]); } } fn load_character_file(path: &Path) -> Option { std::fs::read_to_string(path) .ok() .map(|content| CharacterDefinition::parse(&content)) } fn reply_chara(mut stream: TcpStream, relative_path: &str, mut path: PathBuf) { path.push(relative_path); let Some(def) = load_character_file(&path) else { fail(stream); return; }; let _ = write!(stream, "HTTP/1.1 200 OK\r\n"); let _ = write!(stream, "Content-Type: text/html\r\n"); let _ = write!(stream, "\r\n"); let html = render_character(&def, relative_path); let _ = stream.write_all(html.into_string().as_bytes()); } fn handle_relative_connection( info: Arc, mut stream: TcpStream, body: &str, relative_path: &str, path: &Path, file_paths: DocumentPaths, cookie: Option<&str>, censored: bool, ) { let mut name = None; let mut text = None; let mut up = false; let mut down = false; let mut choice = 0; let mut progress = ""; for entry in body.split('&') { let Some((key, input)) = entry.split_once('=') else { continue; }; match key { "name" => { let name_value = input.replace('+', " "); let decoded_name = percent_decode_str(&name_value).decode_utf8_lossy(); eprintln!("Received comment by \"{decoded_name}\":"); name = Some( decoded_name .replace('&', "&") .replace('<', "<") .replace('>', ">") .into(), ); } "text" => { let text_value = input.replace('+', " "); let decoded_text = percent_decode_str(&text_value).decode_utf8_lossy(); for line in decoded_text.lines() { eprintln!(" {line}"); } eprintln!(); let mut text_buf = Vec::new(); convert(decoded_text.lines(), &mut text_buf); text = Some( std::str::from_utf8(text_buf.as_slice()) .unwrap_or_default() .into(), ); } "up" => up = true, "down" => down = true, "choice" => choice = input.parse().unwrap_or_default(), "progress" => progress = input, _ => (), } } let comments = { let Ok(mut comments) = info.comments.lock() else { return; }; if let (Some(name), Some(text)) = (name, text) { comments.push(Comment { name, text }); } comments.clone() }; let up = if up { info.up.fetch_add(1, Ordering::Relaxed) } else { info.up.load(Ordering::Relaxed) }; let down = if down { info.down.fetch_add(1, Ordering::Relaxed) } else { info.down.load(Ordering::Relaxed) }; let visits = info.visits.fetch_add(1, Ordering::Relaxed); if let Ok(mut file) = File::create(file_paths.data) { if to_stream::(&*info, &mut file).is_err() { eprintln!("Error saving data!"); eprintln!(); } } let _ = write!(stream, "HTTP/1.1 200 OK\r\n"); let _ = write!(stream, "Content-Type: text/html; charset=\"utf-8\"\r\n"); if let Some(password) = cookie { let _ = write!(stream, "Set-Cookie: password={password}\r\n"); } let _ = write!(stream, "\r\n"); let parent_path = relative_path .rsplit_once('/') .map(|(path, _)| path) .unwrap_or_default(); let title = relative_path .rsplit_once('/') .map_or(relative_path, |(_, title)| title); let back = !title.is_empty(); let section = |stream: &mut TcpStream| { let _ = writeln!(stream, "
"); if back { let _ = writeln!(stream, "<< Back"); } }; let _ = writeln!(stream, "

👁️{visits} 💖️{up} 💔️{down}

"); section(&mut stream); fn entry_handler( path: &Path, relative_path: &str, censored: bool, ) -> impl Fn(&str, &mut W, usize) { move |mut entry, output, level| { let level = level + 1; let mut pki_path = path.to_path_buf(); let mut audio_path = path.to_path_buf(); if let Some((real_entry, _)) = entry.split_once(':') { entry = real_entry } let pki_extension = if censored { "pkc" } else { "pki" }; if relative_path.is_empty() { pki_path.push(format!("{entry}.{pki_extension}")); audio_path.push(format!("{entry}.mp3")); } else { pki_path.push(format!("{relative_path}/{entry}.{pki_extension}")); audio_path.push(format!("{relative_path}/{entry}.mp3")); } let Ok(file) = File::open(pki_path) else { return; }; let _ = writeln!( output, "{entry}" ); if Path::is_file(&audio_path) { let _ = writeln!( output, "

" ); } convert_subheader( BufReader::new(file).lines().map(Result::unwrap_or_default), output, level, ); } } if back { let _ = writeln!(stream, "

{title}

"); } enum TabInfo { Lines(Vec), Game(IndexMap, Box>), Description, Comment(Vec), } struct Tab { title: Box, info: TabInfo, } let mut sections = Vec::new(); let check_path: &Path = relative_path.as_ref(); let config_map = check_path .parent() .map(|parent| parse_config(&parent.with_extension("vng")).ok()) .unwrap_or_default(); let mlc_path: Option = check_path .parent() .map(|parent| parent.with_extension("mlc")); if let Some(config_map) = config_map { sections.push(Tab { title: "Game".into(), info: TabInfo::Game(config_map), }); } else { let Ok(pk_file) = File::open(file_paths.pk) else { unreachable!(); }; let mut current_title: Box = "".into(); let mut current_lines = Vec::new(); for line in BufReader::new(pk_file).lines() { let Ok(line) = line else { continue; }; if let Some(title) = line.strip_prefix("# ") { if !current_title.is_empty() { sections.push(Tab { title: current_title, info: TabInfo::Lines(current_lines), }) } current_title = title.into(); current_lines = vec![line]; continue; } current_lines.push(line); } sections.push(Tab { title: current_title, info: TabInfo::Lines(current_lines), }) } if file_paths.pki.is_some() { sections.push(Tab { title: "Description".into(), info: TabInfo::Description, }); } sections.push(Tab { title: "Comments".into(), info: TabInfo::Comment(comments), }); let count = sections.len(); let _ = write!(stream, ""); let _ = write!(stream, r#"
"#); let _ = write!( stream, r#""# ); for i in 2..=count { let _ = write!( stream, r#""# ); } let _ = write!(stream, r#"
"#); for (i, Tab { title, .. }) in sections.iter().enumerate() { let index = i + 1; let _ = write!( stream, r#""# ); } let _ = write!(stream, "
"); for (i, Tab { info, .. }) in sections.into_iter().enumerate() { let index = i + 1; let _ = write!(stream, r#"
"#); match info { TabInfo::Lines(lines) => { convert_extended( lines, &mut stream, Settings::default() .with_handler(entry_handler(path, relative_path, censored)) .with_start_level(1) .with_use_textboxes(true), ); } TabInfo::Game(config_map) => { if render_novel( config_map, file_paths.pk, mlc_path.as_deref(), file_paths.mld, relative_path, &mut stream, choice, progress, ) .is_err() { fail(stream); return; } } TabInfo::Description => { let Ok(pki_file) = File::open(file_paths.pki.unwrap()) else { unreachable!(); }; let _ = writeln!(stream, "

Description

"); if let Some(audio_path) = &file_paths.audio { if Path::is_file(audio_path) { let _ = writeln!( stream, "

" ); } } let lines = BufReader::new(pki_file).lines(); convert_subheader(lines.map(Result::unwrap_or_default), &mut stream, 1); } TabInfo::Comment(comments) => { let html = html! { h2 { "Comments" } form method="POST" { input type="text" name="name" value="anon" placeholder="Name"; br; textarea rows="5" cols="60" name="text" placeholder="Enter comment..." {} br; input type="submit" value="Send!"; } form method="POST" { input type="submit" value="💖️" name="up"; input type="submit" value="💔️" name="down"; } }; let _ = stream.write_all(html.into_string().as_bytes()); for Comment { name, text } in comments { let html = html! { fieldset { legend { (name) } (maud::PreEscaped(text)) } }; let _ = stream.write_all(html.into_string().as_bytes()); } } } let _ = write!(stream, "
"); } let _ = write!(stream, "
"); section(&mut stream); }