Skip to main content

rustio_core/
admin.rs

1//! Auto-generated CRUD admin backed by [`crate::orm`].
2//!
3//! Apply `#[derive(RustioAdmin)]` to any struct that also implements
4//! [`crate::orm::Model`], then call [`register`] on a [`Router`] to mount
5//! list / create / edit / delete routes at `/admin/<admin_name>`.
6
7use bytes::Bytes;
8use http_body_util::{BodyExt, Full};
9
10use crate::auth::require_admin;
11use crate::error::Error;
12use crate::http::{html, Request, Response};
13use crate::orm::{Db, Model};
14use crate::router::Router;
15
16// `FormData` lives in `http` and is re-exported here so that the
17// `#[derive(RustioAdmin)]`-generated code referencing
18// `::rustio_core::admin::FormData` continues to work.
19pub use crate::http::FormData;
20
21#[derive(Debug, Clone, Copy)]
22pub enum FieldType {
23    I32,
24    I64,
25    String,
26    Bool,
27}
28
29#[derive(Debug, Clone, Copy)]
30pub struct AdminField {
31    pub name: &'static str,
32    pub ty: FieldType,
33    pub editable: bool,
34}
35
36pub trait AdminModel: Model {
37    const ADMIN_NAME: &'static str;
38    const DISPLAY_NAME: &'static str;
39    const FIELDS: &'static [AdminField];
40
41    fn field_display(&self, name: &str) -> Option<String>;
42    fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
43}
44
45pub fn register<T>(mut router: Router, db: &Db) -> Router
46where
47    T: AdminModel + Model,
48{
49    let base = format!("/admin/{}", T::ADMIN_NAME);
50    let create_path = format!("{base}/create");
51    let edit_path = format!("{base}/:id/edit");
52    let delete_path = format!("{base}/:id/delete");
53
54    let list_db = db.clone();
55    router = router.get(&base, move |req, _params| {
56        let db = list_db.clone();
57        async move {
58            require_admin(req.ctx())?;
59            let items = T::all(&db).await?;
60            Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
61        }
62    });
63
64    router = router.get(&create_path, |req, _params| async move {
65        require_admin(req.ctx())?;
66        Ok::<Response, Error>(html(admin_layout(
67            &format!("New {}", T::DISPLAY_NAME),
68            &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
69        )))
70    });
71
72    let create_db = db.clone();
73    router = router.post(&create_path, move |req, _params| {
74        let db = create_db.clone();
75        async move {
76            require_admin(req.ctx())?;
77            let form = read_form(req).await?;
78            let item = T::from_form(&form, None)?;
79            item.create(&db).await?;
80            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
81        }
82    });
83
84    let edit_db = db.clone();
85    router = router.get(&edit_path, move |req, params| {
86        let db = edit_db.clone();
87        async move {
88            require_admin(req.ctx())?;
89            let id = parse_id_param(&params)?;
90            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
91            Ok::<Response, Error>(html(admin_layout(
92                &format!("Edit {}", T::DISPLAY_NAME),
93                &form_page::<T>(
94                    Some(&item),
95                    &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
96                ),
97            )))
98        }
99    });
100
101    let update_db = db.clone();
102    router = router.post(&edit_path, move |req, params| {
103        let db = update_db.clone();
104        async move {
105            require_admin(req.ctx())?;
106            let id = parse_id_param(&params)?;
107            let form = read_form(req).await?;
108            let item = T::from_form(&form, Some(id))?;
109            item.update(&db).await?;
110            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
111        }
112    });
113
114    let delete_db = db.clone();
115    router = router.post(&delete_path, move |req, params| {
116        let db = delete_db.clone();
117        async move {
118            require_admin(req.ctx())?;
119            let id = parse_id_param(&params)?;
120            T::delete(&db, id).await?;
121            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
122        }
123    });
124
125    router
126}
127
128fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
129    params
130        .get("id")
131        .and_then(|s| s.parse::<i64>().ok())
132        .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
133}
134
135async fn read_form(req: Request) -> Result<FormData, Error> {
136    let (_, body, _) = req.into_parts();
137    let collected = body
138        .collect()
139        .await
140        .map_err(|e| Error::BadRequest(e.to_string()))?
141        .to_bytes();
142    let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
143    Ok(FormData::parse(body_str))
144}
145
146fn redirect(to: &str) -> Response {
147    hyper::Response::builder()
148        .status(303)
149        .header("location", to)
150        .body(Full::new(Bytes::new()))
151        .expect("valid redirect")
152}
153
154fn admin_layout(title: &str, content: &str) -> String {
155    format!(
156        r#"<!doctype html>
157<html lang="en">
158<head>
159<meta charset="utf-8">
160<meta name="viewport" content="width=device-width, initial-scale=1">
161<title>{title} — RustIO Admin</title>
162<style>{css}</style>
163</head>
164<body>
165<header><h1>RustIO Admin</h1></header>
166<main>{content}</main>
167</body>
168</html>"#,
169        title = escape_html(title),
170        css = ADMIN_CSS,
171        content = content,
172    )
173}
174
175fn list_page<T: AdminModel>(items: &[T]) -> String {
176    let headers: String = T::FIELDS
177        .iter()
178        .map(|f| format!("<th>{}</th>", escape_html(f.name)))
179        .collect();
180    let rows: String = items
181        .iter()
182        .map(|item| {
183            let cells: String = T::FIELDS
184                .iter()
185                .map(|f| {
186                    let v = item.field_display(f.name).unwrap_or_default();
187                    format!("<td>{}</td>", escape_html(&v))
188                })
189                .collect();
190            let id = item.id();
191            let actions = format!(
192                r#"<td class="actions">
193<a href="/admin/{name}/{id}/edit">edit</a>
194<form method="post" action="/admin/{name}/{id}/delete">
195<button type="submit" class="danger">delete</button>
196</form>
197</td>"#,
198                name = T::ADMIN_NAME,
199                id = id,
200            );
201            format!("<tr>{cells}{actions}</tr>")
202        })
203        .collect();
204
205    format!(
206        r#"<div class="toolbar">
207<h2>{title}</h2>
208<a class="button" href="/admin/{name}/create">New {display}</a>
209</div>
210<table>
211<thead><tr>{headers}<th>actions</th></tr></thead>
212<tbody>{rows}</tbody>
213</table>"#,
214        title = escape_html(T::DISPLAY_NAME),
215        display = escape_html(T::DISPLAY_NAME),
216        name = T::ADMIN_NAME,
217    )
218}
219
220fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
221    let fields: String = T::FIELDS
222        .iter()
223        .filter(|f| f.editable)
224        .map(|f| render_field::<T>(f, item))
225        .collect();
226    let heading = if item.is_some() {
227        format!("Edit {}", T::DISPLAY_NAME)
228    } else {
229        format!("New {}", T::DISPLAY_NAME)
230    };
231    format!(
232        r#"<h2>{heading}</h2>
233<form method="post" action="{action}">
234{fields}
235<div class="form-actions">
236<button type="submit">Save</button>
237<a class="cancel" href="/admin/{name}">Cancel</a>
238</div>
239</form>"#,
240        heading = escape_html(&heading),
241        action = escape_html(action),
242        name = T::ADMIN_NAME,
243    )
244}
245
246fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
247    let current = item
248        .and_then(|i| i.field_display(f.name))
249        .unwrap_or_default();
250    let input = match f.ty {
251        FieldType::Bool => format!(
252            r#"<input type="checkbox" name="{n}" {checked}>"#,
253            n = escape_html(f.name),
254            checked = if current == "true" { "checked" } else { "" },
255        ),
256        FieldType::I32 | FieldType::I64 => format!(
257            r#"<input type="number" name="{n}" value="{v}">"#,
258            n = escape_html(f.name),
259            v = escape_html(&current),
260        ),
261        FieldType::String => format!(
262            r#"<input type="text" name="{n}" value="{v}">"#,
263            n = escape_html(f.name),
264            v = escape_html(&current),
265        ),
266    };
267    format!(
268        r#"<label><span>{label}</span>{input}</label>"#,
269        label = escape_html(f.name),
270        input = input,
271    )
272}
273
274fn escape_html(s: &str) -> String {
275    let mut out = String::with_capacity(s.len());
276    for ch in s.chars() {
277        match ch {
278            '&' => out.push_str("&amp;"),
279            '<' => out.push_str("&lt;"),
280            '>' => out.push_str("&gt;"),
281            '"' => out.push_str("&quot;"),
282            '\'' => out.push_str("&#39;"),
283            c => out.push(c),
284        }
285    }
286    out
287}
288
289const ADMIN_CSS: &str = r#"
290*, *::before, *::after { box-sizing: border-box; }
291body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
292  background: #fafafa; color: #222; margin: 0; }
293header { background: #222; color: white; padding: 1rem 2rem; }
294header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
295main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
296h2 { margin: 0; }
297.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
298table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
299  box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
300th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
301th { background: #f4f4f5; font-weight: 600; }
302tbody tr:last-child td { border-bottom: none; }
303td.actions { display: flex; gap: 0.5rem; align-items: center; }
304td.actions form { margin: 0; display: inline; }
305a { color: #0366d6; text-decoration: none; }
306a:hover { text-decoration: underline; }
307label { display: block; margin-bottom: 1rem; }
308label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
309input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
310  border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
311input[type=checkbox] { transform: scale(1.1); }
312button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
313  border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
314button:hover, .button:hover { background: #000; text-decoration: none; }
315button.danger { background: #b42318; }
316button.danger:hover { background: #8a1c12; }
317.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
318.form-actions .cancel { color: #666; }
319"#;
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn escape_html_escapes_dangerous_chars() {
327        assert_eq!(
328            escape_html("<script>alert(\"xss\")</script>"),
329            "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
330        );
331        assert_eq!(escape_html("a & b"), "a &amp; b");
332        assert_eq!(escape_html("it's"), "it&#39;s");
333    }
334}