pukram-server/src/main.rs
2025-08-09 21:49:52 +02:00

702 lines
19 KiB
Rust

use std::{
collections::{HashMap, hash_map::Entry},
env,
fs::File,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
path::{Path, PathBuf},
process::ExitCode,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
thread,
time::Duration,
};
use ::chara::CharacterDefinition;
use comment_system::{Comment, SiteInfo};
use data_stream::{default_settings::PortableSettings, from_stream, to_stream};
use header_config::parse_config;
use maud::html;
use percent_encoding::percent_decode_str;
use pukram2html::convert;
use threadpool::ThreadPool;
mod chara;
mod dialog;
mod request;
mod tabs;
mod vn;
use request::Request;
use tabs::{Tab, TabInfo, write_tab_styles};
#[derive(Default)]
struct SharedSiteInfo {
comments: Mutex<Vec<Comment>>,
visits: AtomicUsize,
up: AtomicUsize,
down: AtomicUsize,
}
impl SharedSiteInfo {
fn new(info: SiteInfo) -> Self {
let SiteInfo {
comments,
visits,
up,
down,
} = info;
Self {
comments: Mutex::new(comments),
visits: visits.into(),
up: up.into(),
down: down.into(),
}
}
fn update(
self: &Arc<Self>,
name: Option<Box<str>>,
text: Option<Box<str>>,
up: bool,
down: bool,
) -> SiteInfo {
SiteInfo {
comments: {
if let Ok(mut comments) = self.comments.lock() {
if let (Some(name), Some(text)) = (name, text) {
comments.push(Comment { name, text });
}
comments.clone()
} else {
Vec::new()
}
},
visits: self.visits.fetch_add(1, Ordering::Relaxed) + 1,
up: if up {
self.up.fetch_add(1, Ordering::Relaxed) + 1
} else {
self.up.load(Ordering::Relaxed)
},
down: if down {
self.down.fetch_add(1, Ordering::Relaxed) + 1
} else {
self.down.load(Ordering::Relaxed)
},
}
}
}
#[derive(Default)]
struct Context {
infos: HashMap<Box<str>, Arc<SharedSiteInfo>>,
}
#[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();
start_server(path, address, 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<String>) {
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();
pool.execute(move || handle_connection(context, path, stream, 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!");
}
fn handle_connection(
context: Arc<Mutex<Context>>,
path: PathBuf,
mut stream: TcpStream,
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,
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);
}
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;
}
}
}
}
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();
{
let (mut path, mut file) = path.rsplit_once('/').unwrap_or(("", relative_path));
let mut redirect = false;
let replaced_path;
if path.contains('_') {
replaced_path = path.replace('_', " ");
path = &replaced_path;
redirect = true;
}
let replaced_file;
if file.contains('_') && !file.contains('.') {
replaced_file = file.replace('_', " ");
file = &replaced_file;
redirect = true;
}
if redirect {
let _ = write!(stream, "HTTP/1.1 308 Permanent Redirect\r\n");
if path.is_empty() {
let _ = write!(stream, "Location: /{file}\r\n\r\n");
} else {
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),
_ => fail(stream),
}
return;
}
if !Path::is_file(&pk_path) {
fail(stream);
return;
}
if let Some(pki_path) = &pki_path
&& !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| {
SharedSiteInfo::new(
from_stream::<PortableSettings, _, _>(&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<CharacterDefinition> {
std::fs::read_to_string(path)
.ok()
.map(|content| CharacterDefinition::parse(&content))
}
struct RequestData<'a> {
name: Option<Box<str>>,
text: Option<Box<str>>,
up: bool,
down: bool,
choice: usize,
progress: &'a str,
}
impl<'a> RequestData<'a> {
fn parse(body: &'a str) -> Self {
let mut data = Self {
name: None,
text: None,
up: false,
down: false,
choice: 0,
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}\":");
data.name = Some(
decoded_name
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.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);
data.text = Some(
std::str::from_utf8(text_buf.as_slice())
.unwrap_or_default()
.into(),
);
}
"up" => data.up = true,
"down" => data.down = true,
"choice" => data.choice = input.parse().unwrap_or_default(),
"progress" => data.progress = input,
_ => (),
}
}
data
}
}
fn handle_relative_connection(
info: Arc<SharedSiteInfo>,
mut stream: TcpStream,
body: &str,
relative_path: &str,
path: &Path,
file_paths: DocumentPaths,
cookie: Option<&str>,
censored: bool,
) {
let RequestData {
name,
text,
up,
down,
choice,
progress,
} = RequestData::parse(body);
let info = info.update(name, text, up, down);
if let Ok(mut file) = File::create(file_paths.data)
&& to_stream::<PortableSettings, _, _>(&info, &mut file).is_err()
{
eprintln!("Error saving data!");
eprintln!();
}
let SiteInfo {
comments,
visits,
up,
down,
} = info;
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, "<hr>");
if back {
let _ = writeln!(stream, "<a href=\"/{parent_path}\">&lt;&lt; Back</a>");
}
};
let _ = writeln!(stream, "<p>👁️{visits} 💖️{up} 💔️{down}</p>");
section(&mut stream);
if back {
let _ = writeln!(stream, "<h1>{title}</h1>");
}
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<PathBuf> = 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<str> = "".into();
let mut current_lines = Vec::new();
let mut chara = false;
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: if chara {
TabInfo::Chara(current_lines)
} else {
TabInfo::Lines(current_lines)
},
})
}
chara = title == "Characters";
current_title = title.into();
current_lines = vec![line];
continue;
}
if chara && line.starts_with('#') {
let mut count = 0;
for c in line.chars() {
if c == '#' {
count += 1;
continue;
}
break;
}
current_lines.push(line.clone());
current_lines.push(format!("+ {}", line[count..].trim()));
} else {
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();
write_tab_styles(&mut stream, count);
let _ = write!(stream, r#"<div class="tab-system">"#);
let _ = write!(
stream,
r#"<input type="radio" id="tab-1" name="tab-group" class="tab-radio" checked>"#
);
for i in 2..=count {
let _ = write!(
stream,
r#"<input type="radio" id="tab-{i}" name="tab-group" class="tab-radio">"#
);
}
let _ = write!(stream, r#"<div class="tab-nav">"#);
for (i, Tab { title, .. }) in sections.iter().enumerate() {
let index = i + 1;
let _ = write!(
stream,
r#"<label for="tab-{index}" class="tab-button">{title}</label>"#
);
}
let _ = write!(stream, "</div>");
for (i, Tab { info, .. }) in sections.into_iter().enumerate() {
let index = i + 1;
let _ = write!(stream, r#"<div class="tab-content" id="content-{index}">"#);
info.render_tab_content(
&mut stream,
path,
relative_path,
censored,
choice,
progress,
mlc_path.as_deref(),
&file_paths,
);
let _ = write!(stream, "</div>");
}
let _ = write!(stream, "</div>");
section(&mut stream);
}