commit - 76047023d4cede330506720b80b2870de0358753
commit + 4ffa5bc4f097afb274875f8caeb52b2f662e8e30
blob - c9a2bda3ca9874ab546594f77d3cd8648a9368d7
blob + 178aea14d678c9a3516eb7e13912b2c896ec3105
--- Cargo.lock
+++ Cargo.lock
]
[[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"
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"
"pledge",
"rand",
"regex",
+ "serde",
+ "serde_json",
"unveil",
]
blob - b9b934e4170c8bf0a4cb39c1f5b644171ad907e3
blob + 1567abaa926be5b605367e65b284a26bbe0ab339
--- Cargo.toml
+++ Cargo.toml
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
+
+pub struct Cookie {
+ name: String,
+ value: String,
+ path: String,
+ expired: bool,
+}
+
+impl Cookie {
+ pub fn new(name: impl Into<String>, value: String) -> Self {
+ let name = name.into();
+ Self {
+ name,
+ value,
+ path: "/".into(),
+ expired: false,
+ }
+ }
+
+ pub fn deleted(name: impl Into<String>) -> 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
-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<String>) -> Page {
- Page {
- title: title.into(),
- html: self,
- }
- }
-}
-
-fn parse() -> Result<Request> {
- 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!("<!DOCTYPE html>");
- 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
-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<Page> {
+pub fn index(req: &Request) -> Result<Response> {
let mut max = 0u32;
let q = &req.query;
}
if max == 0 {
- return Ok(Page {
+ return Ok(Response::page(Page {
title: "Error".into(),
html: html! {
main {
p = "Sorry, I don't have any cat pictures at the moment.";
}
}
- });
+ }));
}
let id = q.parse().unwrap_or(1u32);
}
};
- Ok(Page {
+ Ok(Response::page(Page {
title: "Cats".into(),
html,
- })
+ }))
}
blob - d2e70a182c18856b15b0b8904c6f7728c3026752
blob + 0c261fd4921a600e7f87ebdb370d6c333b09fb92
--- src/site/index.rs
+++ src/site/index.rs
-use crate::{Result, site::Page, html, Request};
+use crate::{html, site::Page, Request, Response, Result};
-pub fn index(_req: &Request) -> Result<Page> {
+pub fn index(_req: &Request) -> Result<Response> {
let html = html! {
main {
h1 = "Index";
}
}
};
- Ok(Page {
+ Ok(Response::page(Page {
title: "Index".into(),
html,
- })
+ }))
}
blob - 42f78aeb43394bc331b6ed365e3f49ee42a0964f
blob + f8cce02bad2d011553c846b6cbcffc604ea25272
--- src/site/mod.rs
+++ src/site/mod.rs
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<Page>;
+pub type Handler = fn(&Request) -> Result<Response>;
struct Route {
prefix: &'static str,
mod cats;
mod time;
mod posts;
+mod upload;
macro_rules! routes {
[$($prefix:literal => $handler:path $([$($label:tt)+])?),* $(,)?] => {
};
}
-fn not_found(req: &Request) -> Result<Page> {
+fn not_found(req: &Request) -> Result<Response> {
let path = &req.path;
- Ok(Page {
+ Ok(Response::page(Page {
title: "Not Found".into(),
html: html! {
main {
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,
];
.expect("failed to find route")
}
-pub fn route(req: &Request) -> Result<Page> {
+pub fn route(req: &Request) -> Result<Response> {
(find_route(req).handler)(req)
}
blob - 5098b96832aeedc5de31179c607cb50dea1646a4
blob + e6495c788125f0b2b311ea2a78732d0db144a889
--- src/site/posts.rs
+++ src/site/posts.rs
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<Page> {
+pub fn posts(req: &Request) -> Result<Response> {
let path = req
.path
.strip_prefix("/posts")
.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
use chrono::Local;
-use crate::{Request, Result, Page, html};
+use crate::{html, Page, Request, Response, Result};
-pub fn index(_req: &Request) -> Result<Page> {
+pub fn index(_req: &Request) -> Result<Response> {
let time = Local::now().to_rfc2822();
- Ok(Page {
+ Ok(Response::page(Page {
title: "Current Time".into(),
html: html! {
main {
p { "{time}"; }
}
},
- })
+ }))
}
blob - /dev/null
blob + 99f3f114b1204df0157192bc83677f895d451d57 (mode 644)
--- /dev/null
+++ src/site/upload.rs
+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<String>,
+}
+
+impl User {
+ fn parse(req: &Request) -> Option<Self> {
+ req
+ .cookie(COOKIE_NAME)
+ .and_then(|form| serde_json::from_str::<User>(form).ok())
+ }
+}
+
+impl Form {
+ fn parse(req: &Request) -> Result<Self> {
+ 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<Response> {
+ let resp = match req.method {
+ Method::Get => {
+ let user = req
+ .cookie(COOKIE_NAME)
+ .and_then(|form| serde_json::from_str::<User>(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<Response> {
+ let resp = Response::redirect("/test/upload/")
+ .with_cookie(Cookie::deleted(COOKIE_NAME));
+ Ok(resp)
+}
+
+pub fn set_color(req: &Request) -> Result<Response> {
+ 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
+use std::collections::HashMap;
+
+use anyhow::Result;
+
+pub struct Request {
+ pub path: String,
+ pub query: String,
+ pub method: Method,
+ pub content_type: Option<String>,
+ pub content_len: Option<usize>,
+ pub form: HashMap<String, String>,
+ pub cookies: HashMap<String, String>,
+}
+
+#[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<Request> {
+ 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<String, String> {
+ 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<String, String> {
+ 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<Self> {
+ match s {
+ "GET" => Some(Self::Get),
+ "POST" => Some(Self::Post),
+ _ => None,
+ }
+ }
+}
blob - /dev/null
blob + c1a965253a82156a85c33e3cccdeb5da15df69df (mode 644)
--- /dev/null
+++ src/response.rs
+use anyhow::Error;
+use crate::{html, html::Element, cookie::Cookie};
+
+pub struct Response {
+ pub content: ResponseContent,
+ pub cookies: Vec<Cookie>,
+}
+
+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!("<!DOCTYPE html>");
+ 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<String>) -> 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!();
+ }
+ }
+ }
+}
+
+