Commit Diff


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<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
@@ -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<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
@@ -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<Page> {
+pub fn index(req: &Request) -> Result<Response> {
     let mut max = 0u32;
     let q = &req.query;
 
@@ -17,7 +17,7 @@ pub fn index(req: &Request) -> Result<Page> {
     }
 
     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<Page> {
 		    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<Page> {
 	}
     };
 
-    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<Page> {
+pub fn index(_req: &Request) -> Result<Response> {
     let html = html! {
 	main {
 	    h1 = "Index";
@@ -13,8 +13,8 @@ pub fn index(_req: &Request) -> Result<Page> {
 	    }
 	}
     };
-    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<Page>;
+pub type Handler = fn(&Request) -> Result<Response>;
 
 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<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 {
@@ -50,13 +50,16 @@ fn not_found(req: &Request) -> Result<Page> {
 		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<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
@@ -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<Page> {
+pub fn posts(req: &Request) -> Result<Response> {
     let path = req
 	.path
 	.strip_prefix("/posts")
@@ -9,11 +9,12 @@ pub fn posts(req: &Request) -> Result<Page> {
 	.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<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 {
@@ -14,5 +14,5 @@ pub fn index(_req: &Request) -> Result<Page> {
 		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<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
@@ -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<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
@@ -0,0 +1,123 @@
+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!();
+	    }
+	}
+    }
+}
+
+