1use std::collections::HashMap;
2
3use bytes::Bytes;
4use http_body_util::{BodyExt, Full};
5
6use crate::auth::require_admin;
7use crate::error::Error;
8use crate::http::{Request, Response, html};
9use crate::orm::{Db, Model};
10use crate::router::Router;
11
12#[derive(Debug, Clone, Copy)]
13pub enum FieldType {
14 I32,
15 I64,
16 String,
17 Bool,
18}
19
20#[derive(Debug, Clone, Copy)]
21pub struct AdminField {
22 pub name: &'static str,
23 pub ty: FieldType,
24 pub editable: bool,
25}
26
27pub trait AdminModel: Model {
28 const ADMIN_NAME: &'static str;
29 const DISPLAY_NAME: &'static str;
30 const FIELDS: &'static [AdminField];
31
32 fn field_display(&self, name: &str) -> Option<String>;
33 fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
34}
35
36pub struct FormData {
37 map: HashMap<String, String>,
38}
39
40impl FormData {
41 pub fn parse(body: &str) -> Self {
42 let mut map = HashMap::new();
43 for pair in body.split('&') {
44 if pair.is_empty() {
45 continue;
46 }
47 let mut iter = pair.splitn(2, '=');
48 let raw_key = match iter.next() {
49 Some(k) if !k.is_empty() => k,
50 _ => continue,
51 };
52 let raw_val = iter.next().unwrap_or("");
53 map.insert(percent_decode(raw_key), percent_decode(raw_val));
54 }
55 FormData { map }
56 }
57
58 pub fn get(&self, key: &str) -> Option<&str> {
59 self.map.get(key).map(String::as_str)
60 }
61
62 pub fn len(&self) -> usize {
63 self.map.len()
64 }
65
66 pub fn is_empty(&self) -> bool {
67 self.map.is_empty()
68 }
69}
70
71pub fn register<T>(mut router: Router, db: &Db) -> Router
72where
73 T: AdminModel + Model,
74{
75 let base = format!("/admin/{}", T::ADMIN_NAME);
76 let create_path = format!("{base}/create");
77 let edit_path = format!("{base}/:id/edit");
78 let delete_path = format!("{base}/:id/delete");
79
80 let list_db = db.clone();
81 router = router.get(&base, move |req, _params| {
82 let db = list_db.clone();
83 async move {
84 require_admin(req.ctx())?;
85 let items = T::all(&db).await?;
86 Ok::<Response, Error>(html(admin_layout(
87 T::DISPLAY_NAME,
88 &list_page::<T>(&items),
89 )))
90 }
91 });
92
93 router = router.get(&create_path, |req, _params| async move {
94 require_admin(req.ctx())?;
95 Ok::<Response, Error>(html(admin_layout(
96 &format!("New {}", T::DISPLAY_NAME),
97 &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
98 )))
99 });
100
101 let create_db = db.clone();
102 router = router.post(&create_path, move |req, _params| {
103 let db = create_db.clone();
104 async move {
105 require_admin(req.ctx())?;
106 let form = read_form(req).await?;
107 let item = T::from_form(&form, None)?;
108 item.create(&db).await?;
109 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
110 }
111 });
112
113 let edit_db = db.clone();
114 router = router.get(&edit_path, move |req, params| {
115 let db = edit_db.clone();
116 async move {
117 require_admin(req.ctx())?;
118 let id = parse_id_param(¶ms)?;
119 let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
120 Ok::<Response, Error>(html(admin_layout(
121 &format!("Edit {}", T::DISPLAY_NAME),
122 &form_page::<T>(
123 Some(&item),
124 &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
125 ),
126 )))
127 }
128 });
129
130 let update_db = db.clone();
131 router = router.post(&edit_path, move |req, params| {
132 let db = update_db.clone();
133 async move {
134 require_admin(req.ctx())?;
135 let id = parse_id_param(¶ms)?;
136 let form = read_form(req).await?;
137 let item = T::from_form(&form, Some(id))?;
138 item.update(&db).await?;
139 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
140 }
141 });
142
143 let delete_db = db.clone();
144 router = router.post(&delete_path, move |req, params| {
145 let db = delete_db.clone();
146 async move {
147 require_admin(req.ctx())?;
148 let id = parse_id_param(¶ms)?;
149 T::delete(&db, id).await?;
150 Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
151 }
152 });
153
154 router
155}
156
157fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
158 params
159 .get("id")
160 .and_then(|s| s.parse::<i64>().ok())
161 .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
162}
163
164async fn read_form(req: Request) -> Result<FormData, Error> {
165 let (_, body, _) = req.into_parts();
166 let collected = body
167 .collect()
168 .await
169 .map_err(|e| Error::BadRequest(e.to_string()))?
170 .to_bytes();
171 let body_str = std::str::from_utf8(&collected)
172 .map_err(|e| Error::BadRequest(e.to_string()))?;
173 Ok(FormData::parse(body_str))
174}
175
176fn redirect(to: &str) -> Response {
177 hyper::Response::builder()
178 .status(303)
179 .header("location", to)
180 .body(Full::new(Bytes::new()))
181 .expect("valid redirect")
182}
183
184fn admin_layout(title: &str, content: &str) -> String {
185 format!(
186 r#"<!doctype html>
187<html lang="en">
188<head>
189<meta charset="utf-8">
190<meta name="viewport" content="width=device-width, initial-scale=1">
191<title>{title} — RustIO Admin</title>
192<style>{css}</style>
193</head>
194<body>
195<header><h1>RustIO Admin</h1></header>
196<main>{content}</main>
197</body>
198</html>"#,
199 title = escape_html(title),
200 css = ADMIN_CSS,
201 content = content,
202 )
203}
204
205fn list_page<T: AdminModel>(items: &[T]) -> String {
206 let headers: String = T::FIELDS
207 .iter()
208 .map(|f| format!("<th>{}</th>", escape_html(f.name)))
209 .collect();
210 let rows: String = items
211 .iter()
212 .map(|item| {
213 let cells: String = T::FIELDS
214 .iter()
215 .map(|f| {
216 let v = item.field_display(f.name).unwrap_or_default();
217 format!("<td>{}</td>", escape_html(&v))
218 })
219 .collect();
220 let id = item.id();
221 let actions = format!(
222 r#"<td class="actions">
223<a href="/admin/{name}/{id}/edit">edit</a>
224<form method="post" action="/admin/{name}/{id}/delete">
225<button type="submit" class="danger">delete</button>
226</form>
227</td>"#,
228 name = T::ADMIN_NAME,
229 id = id,
230 );
231 format!("<tr>{cells}{actions}</tr>")
232 })
233 .collect();
234
235 format!(
236 r#"<div class="toolbar">
237<h2>{title}</h2>
238<a class="button" href="/admin/{name}/create">New {display}</a>
239</div>
240<table>
241<thead><tr>{headers}<th>actions</th></tr></thead>
242<tbody>{rows}</tbody>
243</table>"#,
244 title = escape_html(T::DISPLAY_NAME),
245 display = escape_html(T::DISPLAY_NAME),
246 name = T::ADMIN_NAME,
247 )
248}
249
250fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
251 let fields: String = T::FIELDS
252 .iter()
253 .filter(|f| f.editable)
254 .map(|f| render_field::<T>(f, item))
255 .collect();
256 let heading = if item.is_some() {
257 format!("Edit {}", T::DISPLAY_NAME)
258 } else {
259 format!("New {}", T::DISPLAY_NAME)
260 };
261 format!(
262 r#"<h2>{heading}</h2>
263<form method="post" action="{action}">
264{fields}
265<div class="form-actions">
266<button type="submit">Save</button>
267<a class="cancel" href="/admin/{name}">Cancel</a>
268</div>
269</form>"#,
270 heading = escape_html(&heading),
271 action = escape_html(action),
272 name = T::ADMIN_NAME,
273 )
274}
275
276fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
277 let current = item.and_then(|i| i.field_display(f.name)).unwrap_or_default();
278 let input = match f.ty {
279 FieldType::Bool => format!(
280 r#"<input type="checkbox" name="{n}" {checked}>"#,
281 n = escape_html(f.name),
282 checked = if current == "true" { "checked" } else { "" },
283 ),
284 FieldType::I32 | FieldType::I64 => format!(
285 r#"<input type="number" name="{n}" value="{v}">"#,
286 n = escape_html(f.name),
287 v = escape_html(¤t),
288 ),
289 FieldType::String => format!(
290 r#"<input type="text" name="{n}" value="{v}">"#,
291 n = escape_html(f.name),
292 v = escape_html(¤t),
293 ),
294 };
295 format!(
296 r#"<label><span>{label}</span>{input}</label>"#,
297 label = escape_html(f.name),
298 input = input,
299 )
300}
301
302fn escape_html(s: &str) -> String {
303 let mut out = String::with_capacity(s.len());
304 for ch in s.chars() {
305 match ch {
306 '&' => out.push_str("&"),
307 '<' => out.push_str("<"),
308 '>' => out.push_str(">"),
309 '"' => out.push_str("""),
310 '\'' => out.push_str("'"),
311 c => out.push(c),
312 }
313 }
314 out
315}
316
317fn percent_decode(input: &str) -> String {
318 let bytes = input.as_bytes();
319 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
320 let mut i = 0;
321 while i < bytes.len() {
322 let b = bytes[i];
323 if b == b'+' {
324 out.push(b' ');
325 i += 1;
326 } else if b == b'%' && i + 2 < bytes.len() {
327 if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
328 out.push((h << 4) | l);
329 i += 3;
330 continue;
331 }
332 out.push(b);
333 i += 1;
334 } else {
335 out.push(b);
336 i += 1;
337 }
338 }
339 String::from_utf8_lossy(&out).into_owned()
340}
341
342fn hex_digit(b: u8) -> Option<u8> {
343 match b {
344 b'0'..=b'9' => Some(b - b'0'),
345 b'a'..=b'f' => Some(b - b'a' + 10),
346 b'A'..=b'F' => Some(b - b'A' + 10),
347 _ => None,
348 }
349}
350
351const ADMIN_CSS: &str = r#"
352*, *::before, *::after { box-sizing: border-box; }
353body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
354 background: #fafafa; color: #222; margin: 0; }
355header { background: #222; color: white; padding: 1rem 2rem; }
356header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
357main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
358h2 { margin: 0; }
359.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
360table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
361 box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
362th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
363th { background: #f4f4f5; font-weight: 600; }
364tbody tr:last-child td { border-bottom: none; }
365td.actions { display: flex; gap: 0.5rem; align-items: center; }
366td.actions form { margin: 0; display: inline; }
367a { color: #0366d6; text-decoration: none; }
368a:hover { text-decoration: underline; }
369label { display: block; margin-bottom: 1rem; }
370label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
371input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
372 border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
373input[type=checkbox] { transform: scale(1.1); }
374button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
375 border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
376button:hover, .button:hover { background: #000; text-decoration: none; }
377button.danger { background: #b42318; }
378button.danger:hover { background: #8a1c12; }
379.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
380.form-actions .cancel { color: #666; }
381"#;
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn form_parse_decodes_basic_pairs() {
389 let form = FormData::parse("a=1&b=2");
390 assert_eq!(form.get("a"), Some("1"));
391 assert_eq!(form.get("b"), Some("2"));
392 }
393
394 #[test]
395 fn form_parse_decodes_plus_as_space() {
396 let form = FormData::parse("name=John+Doe");
397 assert_eq!(form.get("name"), Some("John Doe"));
398 }
399
400 #[test]
401 fn form_parse_decodes_percent_encoded() {
402 let form = FormData::parse("q=hello%20world%21");
403 assert_eq!(form.get("q"), Some("hello world!"));
404 }
405
406 #[test]
407 fn form_parse_handles_empty_values() {
408 let form = FormData::parse("a=&b=x");
409 assert_eq!(form.get("a"), Some(""));
410 assert_eq!(form.get("b"), Some("x"));
411 }
412
413 #[test]
414 fn form_parse_ignores_empty_pairs() {
415 let form = FormData::parse("&a=1&&b=2&");
416 assert_eq!(form.get("a"), Some("1"));
417 assert_eq!(form.get("b"), Some("2"));
418 assert_eq!(form.len(), 2);
419 }
420
421 #[test]
422 fn form_missing_key_is_none() {
423 let form = FormData::parse("a=1");
424 assert!(form.get("missing").is_none());
425 }
426
427 #[test]
428 fn escape_html_escapes_dangerous_chars() {
429 assert_eq!(
430 escape_html("<script>alert(\"xss\")</script>"),
431 "<script>alert("xss")</script>"
432 );
433 assert_eq!(escape_html("a & b"), "a & b");
434 assert_eq!(escape_html("it's"), "it's");
435 }
436
437 #[test]
438 fn percent_decode_passes_through_unreserved() {
439 assert_eq!(percent_decode("abcXYZ123-_.~"), "abcXYZ123-_.~");
440 }
441
442 #[test]
443 fn percent_decode_handles_lowercase_and_uppercase_hex() {
444 assert_eq!(percent_decode("%2f%2F"), "//");
445 }
446
447 #[test]
448 fn percent_decode_leaves_invalid_percent_sequences_alone() {
449 assert_eq!(percent_decode("%GG"), "%GG");
450 assert_eq!(percent_decode("end%"), "end%");
451 }
452}