commit 4ffa5bc4f097afb274875f8caeb52b2f662e8e30 from: Benjamin Stürz date: Wed Jun 12 00:40:20 2024 UTC implement school project commit - 76047023d4cede330506720b80b2870de0358753 commit + 4ffa5bc4f097afb274875f8caeb52b2f662e8e30 blob - c9a2bda3ca9874ab546594f77d3cd8648a9368d7 blob + 178aea14d678c9a3516eb7e13912b2c896ec3105 --- Cargo.lock +++ Cargo.lock @@ -126,6 +126,12 @@ dependencies = [ ] [[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] name = "js-sys" version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -264,8 +270,45 @@ name = "regex-syntax" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] name = "syn" version = "2.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -428,5 +471,7 @@ dependencies = [ "pledge", "rand", "regex", + "serde", + "serde_json", "unveil", ] blob - b9b934e4170c8bf0a4cb39c1f5b644171ad907e3 blob + 1567abaa926be5b605367e65b284a26bbe0ab339 --- Cargo.toml +++ Cargo.toml @@ -13,4 +13,6 @@ lazy_static = "1.4.0" pledge = "0.4.2" rand = "0.8.5" regex = "1.10.4" +serde = { version = "1.0.203", features = ["derive"] } +serde_json = "1.0.117" unveil = "0.3.2" blob - /dev/null blob + 1d286188d14b8f252ae490800f2089dd93d9325a (mode 644) --- /dev/null +++ src/cookie.rs @@ -0,0 +1,60 @@ + +pub struct Cookie { + name: String, + value: String, + path: String, + expired: bool, +} + +impl Cookie { + pub fn new(name: impl Into, value: String) -> Self { + let name = name.into(); + Self { + name, + value, + path: "/".into(), + expired: false, + } + } + + pub fn deleted(name: impl Into) -> Self { + let name = name.into(); + Self { + name, + value: "deleted".into(), + path: "/".into(), + expired: true, + } + } + + pub fn with_path(self, path: String) -> Self { + Self { + path, + ..self + } + } + + pub fn name(&self) -> &str { + self.name.as_str() + } + + pub fn value(&self) -> &str { + self.value.as_str() + } + + pub fn delete(self) -> Self { + Self { + expired: true, + ..self + } + } + + pub fn render(&self) { + let mut s = format!("Set-Cookie: {}={}; Path={}; HttpOnly", + self.name, self.value, self.path); + if self.expired { + s += "; Max-Age: 0"; + } + println!("{s}"); + } +} blob - cb97d409ea077b2cfb38f78544d4b3afc41f3298 blob + 1b4c80d1e5a8201c49410613dec3762e60b778ee --- src/main.rs +++ src/main.rs @@ -1,110 +1,32 @@ -pub use anyhow::Result; +pub use anyhow::{Result, Error}; use pledge::pledge; use unveil::unveil; -use crate::{html::Element, site::route}; +pub use crate::{ + request::{Request, Method}, + response::{Page, Response}, +}; + const CONFIG_BASE_DIR: &str = "/htdocs/www-cgi"; mod site; mod html; +mod cookie; +mod request; +mod response; mod markdown; - -pub struct Page { - title: String, - html: Element, -} - -pub struct Request { - pub path: String, - pub query: String, -} - -impl Element { - fn as_page(self, title: impl Into) -> Page { - Page { - title: title.into(), - html: self, - } - } -} - -fn parse() -> Result { - let (path, query) = if std::env::args().count() >= 2 { - let path = std::env::args().nth(1).unwrap(); - let query = std::env::args().nth(2).unwrap_or_else(String::new); - (path, query) - } else { - let path = std::env::var("DOCUMENT_URI")?; - let query = std::env::var("QUERY_STRING")?; - - unveil(CONFIG_BASE_DIR, "r")?; - unveil("", "")?; - pledge("stdio rpath", None)?; - - (path, query) - }; - - let path = path - .strip_prefix("/test") - .expect("failed to strip prefix") - .to_string(); - - Ok(Request { - path, - query, - }) -} - fn main() -> Result<()> { - let req = parse()?; - let (page, ok) = match route(&req) { - Ok(page) => (page, true), - Err(e) => { - let page = Page { - title: "Internal Server Error".into(), - html: html! { - main { - h1 = "Internal Server Error"; - p { - "Sorry, an error occured."; - "Error: {e}"; - } - } - } - }; - (page, false) - }, - }; + unveil(CONFIG_BASE_DIR, "r")?; + unveil("", "")?; + pledge("stdio rpath", None)?; - let top = html! { - html [lang="en"] { - head { - title = format!("{} - Test", page.title); - meta [charset="utf-8"]; - style { - [ - include_str!("style.css") - .lines() - ] - } - //meta ["http-equiv"="Content-Security-Policy", content="default"] {} - } - body [style="background-color: #222; color: #ccc; font-size: 18px"] { - {site::menu()} - {page.html} - } - } - }; + let req = Request::parse()?; - if !ok { - println!("Status: 500 Internal Server Error"); - } - println!("Content-Type: text/html"); - println!("X-Frame-Options: ALLOW-FROM *"); - println!(); - println!(""); - println!("{top}"); + let resp = crate::site::route(&req) + .unwrap_or_else(|e| Response::error(e)); + resp.render(); + Ok(()) } blob - d042dbc2f78261480b0cb1c5ab414f548bc93487 blob + 8de26eac0e3446cc1069f1df99a9ca049a372912 --- src/site/cats.rs +++ src/site/cats.rs @@ -1,7 +1,7 @@ -use crate::{site::Page, html, Result, Request, CONFIG_BASE_DIR}; +use crate::{html, site::Page, Request, Response, Result, CONFIG_BASE_DIR}; use rand::Rng; -pub fn index(req: &Request) -> Result { +pub fn index(req: &Request) -> Result { let mut max = 0u32; let q = &req.query; @@ -17,7 +17,7 @@ pub fn index(req: &Request) -> Result { } if max == 0 { - return Ok(Page { + return Ok(Response::page(Page { title: "Error".into(), html: html! { main { @@ -25,7 +25,7 @@ pub fn index(req: &Request) -> Result { p = "Sorry, I don't have any cat pictures at the moment."; } } - }); + })); } let id = q.parse().unwrap_or(1u32); @@ -68,8 +68,8 @@ pub fn index(req: &Request) -> Result { } }; - Ok(Page { + Ok(Response::page(Page { title: "Cats".into(), html, - }) + })) } blob - d2e70a182c18856b15b0b8904c6f7728c3026752 blob + 0c261fd4921a600e7f87ebdb370d6c333b09fb92 --- src/site/index.rs +++ src/site/index.rs @@ -1,6 +1,6 @@ -use crate::{Result, site::Page, html, Request}; +use crate::{html, site::Page, Request, Response, Result}; -pub fn index(_req: &Request) -> Result { +pub fn index(_req: &Request) -> Result { let html = html! { main { h1 = "Index"; @@ -13,8 +13,8 @@ pub fn index(_req: &Request) -> Result { } } }; - Ok(Page { + Ok(Response::page(Page { title: "Index".into(), html, - }) + })) } blob - 42f78aeb43394bc331b6ed365e3f49ee42a0964f blob + f8cce02bad2d011553c846b6cbcffc604ea25272 --- src/site/mod.rs +++ src/site/mod.rs @@ -1,9 +1,8 @@ use std::iter::once; - use itertools::Itertools; -use crate::{Result, Page, Request, html, html::Element}; +use crate::{html, html::Element, Page, Request, Response, Result}; -pub type Handler = fn(&Request) -> Result; +pub type Handler = fn(&Request) -> Result; struct Route { prefix: &'static str, @@ -15,6 +14,7 @@ mod index; mod cats; mod time; mod posts; +mod upload; macro_rules! routes { [$($prefix:literal => $handler:path $([$($label:tt)+])?),* $(,)?] => { @@ -40,9 +40,9 @@ macro_rules! routes { }; } -fn not_found(req: &Request) -> Result { +fn not_found(req: &Request) -> Result { let path = &req.path; - Ok(Page { + Ok(Response::page(Page { title: "Not Found".into(), html: html! { main { @@ -50,13 +50,16 @@ fn not_found(req: &Request) -> Result { p { "Invalid path: {path:?}"; } } }, - }) + })) } const ROUTES: &[Route] = routes![ "/time/" => time::index ["Time" : 2], "/cats/" => cats::index ["Cats" : 1], "/posts/*" => posts::posts ["Posts" : 3], + "/upload/set-color/" => upload::set_color, + "/upload/logout/" => upload::logout, + "/upload/" => upload::index, "/" => index::index ["Index" : 0], "/*" => not_found, ]; @@ -76,7 +79,7 @@ fn find_route<'a>(req: &'a Request) -> &'a Route { .expect("failed to find route") } -pub fn route(req: &Request) -> Result { +pub fn route(req: &Request) -> Result { (find_route(req).handler)(req) } blob - 5098b96832aeedc5de31179c607cb50dea1646a4 blob + e6495c788125f0b2b311ea2a78732d0db144a889 --- src/site/posts.rs +++ src/site/posts.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use crate::{Request, Page, html, CONFIG_BASE_DIR}; +use crate::{html, Page, Request, Response, CONFIG_BASE_DIR}; -pub fn posts(req: &Request) -> Result { +pub fn posts(req: &Request) -> Result { let path = req .path .strip_prefix("/posts") @@ -9,11 +9,12 @@ pub fn posts(req: &Request) -> Result { .trim_end_matches('/'); - if path.is_empty() { + let page = if path.is_empty() { index() } else { render(path) - } + }?; + Ok(Response::page(page)) } blob - 62feb4253c0a8f7b27fb49558b2097a6e6cd7e1e blob + 15d3212ae278ee5bc9f9a0378ee5b00571c3f8f0 --- src/site/time.rs +++ src/site/time.rs @@ -1,9 +1,9 @@ use chrono::Local; -use crate::{Request, Result, Page, html}; +use crate::{html, Page, Request, Response, Result}; -pub fn index(_req: &Request) -> Result { +pub fn index(_req: &Request) -> Result { let time = Local::now().to_rfc2822(); - Ok(Page { + Ok(Response::page(Page { title: "Current Time".into(), html: html! { main { @@ -14,5 +14,5 @@ pub fn index(_req: &Request) -> Result { p { "{time}"; } } }, - }) + })) } blob - /dev/null blob + 99f3f114b1204df0157192bc83677f895d451d57 (mode 644) --- /dev/null +++ src/site/upload.rs @@ -0,0 +1,178 @@ +use anyhow::Context; +use crate::{cookie::Cookie, html, site::Page, Method, Request, Response, Result}; +use serde::{Serialize, Deserialize}; + +const COOKIE_NAME: &str = "userdata"; + +struct Form { + name: String, + age: u32, +} + +#[derive(Serialize, Deserialize)] +struct User { + name: String, + age: u32, + color: Option, +} + +impl User { + fn parse(req: &Request) -> Option { + req + .cookie(COOKIE_NAME) + .and_then(|form| serde_json::from_str::(form).ok()) + } +} + +impl Form { + fn parse(req: &Request) -> Result { + let name = req + .form + .get("name") + .context("form invalid: no name")? + .into(); + let age = req + .form + .get("age") + .context("form invalid: no age")? + .parse() + .context("form invalid: invalid age")?; + Ok(Self { + name, + age, + }) + } +} + +pub fn index(req: &Request) -> Result { + let resp = match req.method { + Method::Get => { + let user = req + .cookie(COOKIE_NAME) + .and_then(|form| serde_json::from_str::(form).ok()); + + let colors = [ + ("Red", 0xFF0000), + ("Green", 0x00FF00), + ("Blue", 0x0000FF), + ]; + + let html = match user { + Some(User { name, age, color }) => html! { + div { + ?{ + color + .map(|color| html! { + style { + "p, sup, span {{ color: {color}; }}"; + } + }) + } + p { + "Guten Tag {name}!"; + br; + "Sie sind angeblich {age} Jahre alt."; + } + form [action="/test/upload/set-color/", method="POST"] { + select [name="color"] { + option [value="none"] { "None"; } + [ + colors + .iter() + .map(|(name, value)| html! { + option [value=format!("#{value:06x}")] { {*name} } + }) + ] + } + br; + input [type="submit", value="Farbe"] {} + } + br; + form [action="/test/upload/logout/"] { + input [type="submit", value="Abmelden"] {} + } + } + }, + None => html! { + div { + p { + "Hallo unbekannter User!"; + br; + "Bitte melde dich an."; + } + form [action="/test/upload/", method="POST"] { + label [for="name"] { "Name:"; } + br; + input [type="text", name="name"] {} + br; + label [for="age"] { "Age:"; } + br; + input [type="text", name="age"] {} + br; + input [type="submit"] {} + } + } + }, + }; + + let html = html! { + main { + h1 = "Semesteraufgabe CGI"; + {html} + br; + hr; + sup { + "Den Source-Code von dieser Aufgabe finden Sie unter"; + a [href="https://got.stuerz.xyz/?action=summary&path=www-cgi.git"] { "Code"; } + "in der Datei 'src/site/upload.rs'."; + } + } + }; + + Response::page(Page { + title: "Semesteraufgabe CGI".into(), + html, + }) + }, + Method::Post => { + match Form::parse(req) { + Ok(form) => { + let user = User { + name: form.name, + age: form.age, + color: None, + }; + Response::redirect("/test/upload/") + .with_cookie(Cookie::new(COOKIE_NAME, serde_json::to_string(&user)?)) + }, + Err(e) => Response::error(e), + } + }, + }; + Ok(resp) +} + + +pub fn logout(_req: &Request) -> Result { + let resp = Response::redirect("/test/upload/") + .with_cookie(Cookie::deleted(COOKIE_NAME)); + Ok(resp) +} + +pub fn set_color(req: &Request) -> Result { + let mut user = User::parse(req) + .context("no user")?; + + let color = req + .form + .get("color") + .context("no color")?; + + user.color = if color != "none" { Some(color.into()) } else { None }; + + let user = serde_json::to_string(&user)?; + + let resp = Response::redirect("/test/upload/") + .with_cookie(Cookie::new(COOKIE_NAME, user)); + Ok(resp) +} blob - /dev/null blob + e4ab53f4c19ef0ac9828d79d06013b9fd2f56939 (mode 644) --- /dev/null +++ src/request.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use anyhow::Result; + +pub struct Request { + pub path: String, + pub query: String, + pub method: Method, + pub content_type: Option, + pub content_len: Option, + pub form: HashMap, + pub cookies: HashMap, +} + +#[derive(Debug, Clone, Copy)] +pub enum Method { + Get, + Post, +} + +impl Request { + pub fn cookie(&self, name: &str) -> Option<&str> { + self + .cookies + .get(name) + .map(|s| s.as_str()) + } + + pub fn parse() -> Result { + let path = std::env::var("DOCUMENT_URI")?; + let query = std::env::var("QUERY_STRING")?; + + let method = std::env::var("REQUEST_METHOD") + .ok() + .as_deref() + .and_then(|m| Method::parse(m)) + .unwrap_or(Method::Get); + + let content_type = std::env::var("CONTENT_TYPE").ok(); + + // TODO: error if invalid parameter + let content_len = std::env::var("CONTENT_LENGTH") + .ok() + .and_then(|t| t.parse().ok()); + + let path = path + .strip_prefix("/test") + .expect("failed to strip prefix") + .to_string(); + + let cookies = Self::parse_cookie(); + let form = Self::parse_body(); + + Ok(Request { + path, + query, + method, + content_type, + content_len, + form, + cookies, + }) + } + + fn parse_cookie() -> HashMap { + std::env::var("HTTP_COOKIE") + .unwrap_or_else(|_| String::new()) + .split(';') + .flat_map(|s| s.trim().split_once('=')) + .map(|(n, v)| (n.into(), v.into())) + .collect() + } + + fn parse_body() -> HashMap { + let parse_value = |s: &str| { + let mut out = Vec::new(); + let mut chars = s.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '+' => out.push(b' '), + '%' => { + let mut b = 0; + + for _ in 0..2 { + let d = chars + .next() + .and_then(|ch| ch.to_digit(16)) + .unwrap(); + b = b * 16 + d as u8; + } + + out.push(b); + }, + _ => out.extend_from_slice(char::encode_utf8(ch, &mut [0; 4]).as_bytes()), + } + } + + out.retain(|b| *b != b'\r'); + String::from_utf8_lossy(&out).into_owned() + }; + + let mut body = String::new(); + let _ = std::io::stdin().read_line(&mut body); + body + .split('&') + .flat_map(|s| s.split_once('=')) + .map(|(n, v)| (n.into(), parse_value(v))) + .collect() + } +} + +impl Method { + fn parse(s: &str) -> Option { + match s { + "GET" => Some(Self::Get), + "POST" => Some(Self::Post), + _ => None, + } + } +} blob - /dev/null blob + c1a965253a82156a85c33e3cccdeb5da15df69df (mode 644) --- /dev/null +++ src/response.rs @@ -0,0 +1,123 @@ +use anyhow::Error; +use crate::{html, html::Element, cookie::Cookie}; + +pub struct Response { + pub content: ResponseContent, + pub cookies: Vec, +} + +pub enum ResponseContent { + Page(Page), + Redirect(String), + Error(Error), +} + +pub struct Page { + pub title: String, + pub html: Element, +} + +impl Page { + fn render(self) { + let body = html! { + html [lang="en"] { + head { + title = format!("{} - stuerz.xyz", self.title); + meta [charset="utf-8"]; + style { + [ + include_str!("style.css") + .lines() + ] + } + //meta ["http-equiv"="Content-Security-Policy", content="default"] {} + } + body [style="background-color: #222; color: #ccc; font-size: 18px"] { + {crate::site::menu()} + {self.html} + } + } + }; + println!("Content-Type: text/html"); + println!(); + println!(""); + println!("{body}"); + } + + fn from_error(e: Error) -> Self { + Page { + title: "Internal Server Error".into(), + html: html! { + main { + h1 = "Internal Server Error"; + p { + "Sorry, an error occured."; + "Error: {e}"; + } + } + } + } + } +} + +impl Response { + pub fn new(content: ResponseContent) -> Self { + Self { + content, + cookies: Vec::new(), + } + } + pub fn page(page: Page) -> Self { + Self::new(ResponseContent::Page(page)) + } + pub fn error(error: Error) -> Self { + Self::new(ResponseContent::Error(error)) + } + pub fn redirect(to: impl Into) -> Self { + Self::new(ResponseContent::Redirect(to.into())) + } + + pub fn set_cookie(&mut self, cookie: Cookie) { + self.cookies.push(cookie) + } + + pub fn with_cookie(mut self, cookie: Cookie) -> Self { + self.set_cookie(cookie); + self + } + + pub fn render(self) { + for cookie in self.cookies { + cookie.render(); + } + self.content.render(); + } +} + +impl ResponseContent { + fn code(&self) -> u32 { + match self { + Self::Page(_) => 200, + Self::Redirect(_) => 301, + Self::Error(_) => 500, + } + } + + pub fn render(self) { + println!("Status: {}", self.code()); + println!("X-Frame-Options: ALLOW-FROM *"); + println!("Cache-Control: no-cache, must-revalidate"); + println!("Pragma: no-cache"); + println!("Expires: 0"); + match self { + Self::Page(page) => page.render(), + Self::Error(e) => Page::from_error(e).render(), + Self::Redirect(to) => { + println!("Location: {to}"); + println!(); + } + } + } +} + +