Skip to main content

rustio_core/
admin.rs

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(&params)?;
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(&params)?;
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(&params)?;
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(&current),
288        ),
289        FieldType::String => format!(
290            r#"<input type="text" name="{n}" value="{v}">"#,
291            n = escape_html(f.name),
292            v = escape_html(&current),
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("&amp;"),
307            '<' => out.push_str("&lt;"),
308            '>' => out.push_str("&gt;"),
309            '"' => out.push_str("&quot;"),
310            '\'' => out.push_str("&#39;"),
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            "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
432        );
433        assert_eq!(escape_html("a & b"), "a &amp; b");
434        assert_eq!(escape_html("it's"), "it&#39;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}